From b9d5a1edff5cc9a7b3d6c183f8be5b743dce5ebb Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Mon, 12 Sep 2022 20:14:03 +0200 Subject: [PATCH] More work on making flyers --- UI/Base/Minimap.ts | 6 +- UI/Base/MinimapImplementation.ts | 20 +- UI/Input/LocationInput.ts | 10 +- Utils/pngMapCreator.ts | 102 +++++ Utils/svgToPdf.ts | 613 +++++++++++++++++++++++++++++++ langs/en.json | 24 ++ langs/nl.json | 24 ++ test.ts | 450 ++--------------------- 8 files changed, 831 insertions(+), 418 deletions(-) create mode 100644 Utils/pngMapCreator.ts create mode 100644 Utils/svgToPdf.ts diff --git a/UI/Base/Minimap.ts b/UI/Base/Minimap.ts index 9ae16a770..bbd764fb5 100644 --- a/UI/Base/Minimap.ts +++ b/UI/Base/Minimap.ts @@ -3,6 +3,7 @@ import Loc from "../../Models/Loc" import BaseLayer from "../../Models/BaseLayer" import { UIEventSource } from "../../Logic/UIEventSource" import { BBox } from "../../Logic/BBox" +import {deprecate} from "util"; export interface MinimapOptions { background?: UIEventSource @@ -24,7 +25,10 @@ export interface MinimapObj { installBounds(factor: number | BBox, showRange?: boolean): void - TakeScreenshot(): Promise + TakeScreenshot(format): Promise + TakeScreenshot(format: "image"): Promise + TakeScreenshot(format:"blob"): Promise + TakeScreenshot(format?: "image" | "blob"): Promise } export default class Minimap { diff --git a/UI/Base/MinimapImplementation.ts b/UI/Base/MinimapImplementation.ts index 602148fab..8ec5c8522 100644 --- a/UI/Base/MinimapImplementation.ts +++ b/UI/Base/MinimapImplementation.ts @@ -109,10 +109,26 @@ export default class MinimapImplementation extends BaseUIElement implements Mini mp.remove() } - public async TakeScreenshot() { + /** + * Takes a screenshot of the current map + * @param format: image: give a base64 encoded png image; + * @constructor + */ + public async TakeScreenshot(): Promise ; + public async TakeScreenshot(format: "image"): Promise ; + public async TakeScreenshot(format: "blob"): Promise ; + public async TakeScreenshot(format: "image" | "blob"): Promise ; + public async TakeScreenshot(format: "image" | "blob" = "image"): Promise { const screenshotter = new SimpleMapScreenshoter() screenshotter.addTo(this.leafletMap.data) - return await screenshotter.takeScreen("image") + const result = await screenshotter.takeScreen(( format) ?? "image") + if(format === "image" && typeof result === "string"){ + return result + } + if(format === "blob" && result instanceof Blob){ + return result + } + throw "Something went wrong while creating the screenshot: "+result } protected InnerConstructElement(): HTMLElement { diff --git a/UI/Input/LocationInput.ts b/UI/Input/LocationInput.ts index 1d7025053..809f6d77b 100644 --- a/UI/Input/LocationInput.ts +++ b/UI/Input/LocationInput.ts @@ -32,7 +32,7 @@ export default class LocationInput public readonly leafletMap: UIEventSource public readonly bounds public readonly location - private _centerLocation: UIEventSource + private readonly _centerLocation: UIEventSource private readonly mapBackground: UIEventSource /** * The features to which the input should be snapped @@ -177,10 +177,6 @@ export default class LocationInput this.map.installBounds(factor, showRange) } - TakeScreenshot(): Promise { - return this.map.TakeScreenshot() - } - protected InnerConstructElement(): HTMLElement { try { const self = this @@ -270,4 +266,8 @@ export default class LocationInput .ConstructElement() } } + + TakeScreenshot(): Promise { + return this.map.TakeScreenshot() + } } diff --git a/Utils/pngMapCreator.ts b/Utils/pngMapCreator.ts new file mode 100644 index 000000000..8a81c0b39 --- /dev/null +++ b/Utils/pngMapCreator.ts @@ -0,0 +1,102 @@ +import FeaturePipelineState from "../Logic/State/FeaturePipelineState"; +import MinimapImplementation from "../UI/Base/MinimapImplementation"; +import {UIEventSource} from "../Logic/UIEventSource"; +import Loc from "../Models/Loc"; +import ShowDataLayer from "../UI/ShowDataLayer/ShowDataLayer"; +import {BBox} from "../Logic/BBox"; +import Minimap from "../UI/Base/Minimap"; + +export class PngMapCreator { + private readonly _state: FeaturePipelineState; + private readonly _options: { + readonly divId: string; readonly width: number; readonly height: number; readonly scaling?: 1 | number + }; + + constructor(state: FeaturePipelineState, options: { + readonly divId: string + readonly width: number, + readonly height: number, + readonly scaling?: 1 | number + }) { + this._state = state; + this._options = {...options, scaling: options.scaling ?? 1}; + } + + /** + * Creates a minimap, waits till all needed tiles are loaded before returning + * @private + */ + private async createAndLoadMinimap(): Promise { + const state = this._state; + const options = this._options + return new Promise(resolve => { + const minimap = Minimap.createMiniMap({ + location: new UIEventSource(state.locationControl.data), // We remove the link between the old and the new UI-event source as moving the map while the export is running fucks up the screenshot + background: state.backgroundLayer, + allowMoving: false, + onFullyLoaded: (_) => + window.setTimeout(() => { + resolve(minimap) + }, 250) + }) + const style = `width: ${options.width * options.scaling}mm; height: ${options.height * options.scaling}mm;` + console.log("Style is", style) + minimap.SetStyle(style) + minimap.AttachTo(options.divId) + }) + + } + + /** + * Creates a base64-encoded PNG image + * @constructor + */ + public async CreatePng(format: "image" ): Promise ; + public async CreatePng(format: "blob"): Promise ; + public async CreatePng(format: "image" | "blob"): Promise; + public async CreatePng(format: "image" | "blob"): Promise { + + // Lets first init the minimap and wait for all background tiles to load + const minimap = await this.createAndLoadMinimap() + const state = this._state + + return new Promise(resolve => { + // Next: we prepare the features. Only fully contained features are shown + minimap.leafletMap.addCallbackAndRunD(async (leaflet) => { + const bounds = BBox.fromLeafletBounds(leaflet.getBounds().pad(0.2)) + // Ping the featurepipeline to download what is needed + state.currentBounds.setData(bounds) + if(state.featurePipeline.runningQuery.data){ + // A query is running! + // Let's wait for it to complete + console.log("Waiting for the query to complete") + await state.featurePipeline.runningQuery.AsPromise() + } + + window.setTimeout(() => { + + + state.featurePipeline.GetTilesPerLayerWithin(bounds, (tile) => { + + if (tile.layer.layerDef.minzoom > state.locationControl.data.zoom) { + return + } + if (tile.layer.layerDef.id.startsWith("note_import")) { + // Don't export notes to import + return + } + new ShowDataLayer({ + features: tile, + leafletMap: minimap.leafletMap, + layerToShow: tile.layer.layerDef, + doShowLayer: tile.layer.isDisplayed, + state: undefined, + }) + }) + minimap.TakeScreenshot(format).then(result => resolve(result)) + }, 2500) + }) + state.AddAllOverlaysToMap(minimap.leafletMap) + }) + } +} diff --git a/Utils/svgToPdf.ts b/Utils/svgToPdf.ts new file mode 100644 index 000000000..6901fb85b --- /dev/null +++ b/Utils/svgToPdf.ts @@ -0,0 +1,613 @@ +import jsPDF, {Matrix} from "jspdf"; +import Translations from "../UI/i18n/Translations"; +import {Translation, TypedTranslation} from "../UI/i18n/Translation"; +import FeaturePipelineState from "../Logic/State/FeaturePipelineState"; +import {PngMapCreator} from "./pngMapCreator"; +import {AllKnownLayouts} from "../Customizations/AllKnownLayouts"; + +class SvgToPdfInternals { + private readonly doc: jsPDF; + private static readonly dummyDoc: jsPDF = new jsPDF() + private readonly textSubstitutions: Record; + private readonly matrices: Matrix[] = [] + private readonly matricesInverted: Matrix[] = [] + + private currentMatrix: Matrix; + private currentMatrixInverted: Matrix; + + private readonly _images: Record; + private readonly _rects: Record; + + constructor(advancedApi: jsPDF, textSubstitutions: Record, images: Record, rects: Record) { + this.textSubstitutions = textSubstitutions; + this.doc = advancedApi; + this._images = images; + this._rects = rects; + this.currentMatrix = this.doc.unitMatrix; + this.currentMatrixInverted = this.doc.unitMatrix; + } + + applyMatrices(): void { + let multiplied = this.doc.unitMatrix; + let multipliedInv = this.doc.unitMatrix; + for (const matrix of this.matrices) { + multiplied = this.doc.matrixMult(multiplied, matrix) + } + for (const matrix of this.matricesInverted) { + multipliedInv = this.doc.matrixMult(multiplied, matrix) + } + this.currentMatrix = multiplied + this.currentMatrixInverted = multipliedInv + } + + addMatrix(m: Matrix) { + this.matrices.push(m) + this.matricesInverted.push(m.inversed()) + this.doc.setCurrentTransformationMatrix(m); + this.applyMatrices() + + } + + public static extractMatrix(element: Element): Matrix { + + const t = element.getAttribute("transform") + if (t === null) { + return null; + } + const scaleMatch = t.match(/scale\(([-0-9.]*)\)/) + if (scaleMatch !== null) { + const s = Number(scaleMatch[1]) + return SvgToPdfInternals.dummyDoc.Matrix(1 / s, 0, 0, 1 / s, 0, 0); + } + + const transformMatch = t.match(/matrix\(([-0-9.]*),([-0-9.]*),([-0-9.]*),([-0-9.]*),([-0-9.]*),([-0-9.]*)\)/) + if (transformMatch !== null) { + const vals = [1, 0, 0, 1, 0, 0] + const invVals = [1, 0, 0, 1, 0, 0] + for (let i = 0; i < 6; i++) { + const ti = Number(transformMatch[i + 1]) + if (ti == 0) { + vals[i] = 0 + } else { + invVals[i] = 1 / ti + vals[i] = ti + } + } + return SvgToPdfInternals.dummyDoc.Matrix(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]); + } + + return null; + } + + public setTransform(element: Element): boolean { + + const m = SvgToPdfInternals.extractMatrix(element) + if (m === null) { + return false; + } + this.addMatrix(m) + return true; + } + + public undoTransform(): void { + this.matrices.pop() + const i = this.matricesInverted.pop() + this.doc.setCurrentTransformationMatrix(i) + this.applyMatrices() + + } + + public static parseCss(styleContent: string, separator: string = ";"): Record { + if (styleContent === undefined || styleContent === null) { + return {} + } + const r: Record = {} + + for (const rule of styleContent.split(separator)) { + const [k, v] = rule.split(":").map(x => x.trim()) + r[k] = v + } + + return r + }; + + private drawRect(element: Element) { + const x = Number(element.getAttribute("x")) + const y = Number(element.getAttribute("y")) + const width = Number(element.getAttribute("width")) + const height = Number(element.getAttribute("height")) + const style = element.getAttribute("style") + + const css = SvgToPdfInternals.parseCss(style) + if (css["fill-opacity"] !== "0") { + this.doc.setFillColor(css["fill"] ?? "black") + this.doc.rect(x, y, width, height, "F") + } + if (css["stroke"]) { + this.doc.setLineWidth(Number(css["stroke-width"] ?? 1)) + this.doc.setDrawColor(css["stroke"] ?? "black") + this.doc.rect(x, y, width, height, "S") + } + return + } + + private static attr(element: Element, name: string, recurseup: boolean = true): string | undefined { + if (element === null || element === undefined) { + return undefined + } + const a = element.getAttribute(name) + if (a !== null && a !== undefined) { + return a + } + if (recurseup && element.parentElement !== undefined && element.parentElement !== element) { + return SvgToPdfInternals.attr(element.parentElement, name, recurseup) + } + return undefined + } + + /** + * Reads the 'style'-element recursively + * @param element + * @private + */ + private static css(element: Element): Record { + + if (element.parentElement == undefined || element.parentElement == element) { + return SvgToPdfInternals.parseCss(element.getAttribute("style")) + } + + const css = SvgToPdfInternals.css(element.parentElement); + const style = element.getAttribute("style") + if (style === undefined || style == null) { + return css + } + for (const rule of style.split(";")) { + const [k, v] = rule.split(":").map(x => x.trim()) + css[k] = v + } + return css + + } + + static attrNumber(element: Element, name: string, recurseup: boolean = true): number { + const a = SvgToPdfInternals.attr(element, name, recurseup) + const n = parseFloat(a) + if (!isNaN(n)) { + return n + } + return undefined + } + + private extractTranslation(text: string) { + const pathPart = text.match(/\$(([a-zA-Z0-9]+\.)+[a-zA-Z0-9]+)(.*)/) + if (pathPart === null) { + return text + } + const path = pathPart[1].split(".") + const rest = pathPart[3] ?? "" + let t: any = Translations.t + for (const crumb of path) { + t = t[crumb] + if (t === undefined) { + console.error("No value found to substitute " + text) + return undefined + } + } + if (t instanceof TypedTranslation) { + return (>t).Subs(this.textSubstitutions).txt + rest + } else { + return (t).txt + rest + } + } + + private drawTspan(tspan: Element) { + if (tspan.textContent == "") { + return + } + const x = SvgToPdfInternals.attrNumber(tspan, "x") + const y = SvgToPdfInternals.attrNumber(tspan, "y") + + const css = SvgToPdfInternals.css(tspan) + let maxWidth: number = undefined + if (css["shape-inside"]) { + const matched = css["shape-inside"].match(/url\(#([a-zA-Z0-9-]+)\)/) + if (matched !== null) { + const rectId = matched[1] + const rect = this._rects[rectId] + maxWidth = SvgToPdfInternals.attrNumber(rect, "width", false) + } + } + + let fontFamily = css["font-family"] ?? "Ubuntu"; + if (fontFamily === "sans-serif") { + fontFamily = "Ubuntu" + } + + let fontWeight = css["font-weight"] ?? "normal"; + this.doc.setFont(fontFamily, fontWeight) + + + const fontColor = css["fill"] + if (fontColor) { + this.doc.setTextColor(fontColor) + } else { + this.doc.setTextColor("black") + } + let fontsize = parseFloat(css["font-size"]) + + this.doc.setFontSize(fontsize * 2.5) + + let textTemplate = tspan.textContent.split(" ") + let result: string[] = [] + for (let text of textTemplate) { + + + if (!text.startsWith("$")) { + result.push(text) + continue + } + if (text.startsWith("$list(")) { + text = text.substring("$list(".length, text.length - ")".length) + result.push("\n") + let r = this.extractTranslation("$" + text + "0"); + let i = 0 + while (r !== undefined && i < 100) { + result.push("• " + r + "\n") + i++ + r = this.extractTranslation("$" + text + i); + } + } else { + const found = this.extractTranslation(text) ?? text + result.push(found) + } + + } + this.doc.text(result.join(" "), x, y, { + maxWidth, + }, this.currentMatrix) + } + + private drawSvgViaCanvas(element: Element): void { + const x = SvgToPdfInternals.attrNumber(element, "x") + const y = SvgToPdfInternals.attrNumber(element, "y") + const width = SvgToPdfInternals.attrNumber(element, "width") + const height = SvgToPdfInternals.attrNumber(element, "height") + const base64src = SvgToPdfInternals.attr(element, "xlink:href") + const svgXml = atob(base64src.substring(base64src.indexOf(";base64,") + ";base64,".length)); + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(svgXml, "text/xml"); + const svgRoot = xmlDoc.getElementsByTagName("svg")[0]; + const svgWidth = SvgToPdfInternals.attrNumber(svgRoot, "width") + const svgHeight = SvgToPdfInternals.attrNumber(svgRoot, "height") + + + let img = this._images[base64src] + // This is an svg image, we use the canvas to convert it to a png + const canvas = document.createElement("canvas") + const ctx = canvas.getContext("2d") + + canvas.width = svgWidth + canvas.height = svgHeight + img.style.width = `${(svgWidth)}px` + img.style.height = `${(svgHeight)}px` + + ctx.drawImage(img, 0, 0, svgWidth, svgHeight) + const base64img = canvas.toDataURL("image/png") + + this.addMatrix(this.doc.Matrix(width / svgWidth, 0, 0, height / svgHeight, 0, 0)) + const p = this.currentMatrixInverted.applyToPoint({x, y}) + this.doc.addImage(base64img, "png", p.x * svgWidth / width, p.y * svgHeight / height, svgWidth, svgHeight) + this.undoTransform() + } + + private drawImage(element: Element): void { + const href = SvgToPdfInternals.attr(element, "xlink:href") + if (href.endsWith('svg') || href.startsWith("data:image/svg")) { + this.drawSvgViaCanvas(element); + } else { + const x = SvgToPdfInternals.attrNumber(element, "x") + const y = SvgToPdfInternals.attrNumber(element, "y") + const width = SvgToPdfInternals.attrNumber(element, "width") + const height = SvgToPdfInternals.attrNumber(element, "height") + const base64src = SvgToPdfInternals.attr(element, "xlink:href") + + this.doc.addImage(base64src, x, y, width, height) + } + } + + public handleElement(element: SVGSVGElement | Element): void { + const isTransformed = this.setTransform(element) + if (element.tagName === "tspan") { + if (element.childElementCount == 0) { + this.drawTspan(element) + } else { + for (let child of Array.from(element.children)) { + this.handleElement(child) + } + } + } + + if (element.tagName === "image") { + this.drawImage(element) + } + + if (element.tagName === "g" || element.tagName === "text") { + + for (let child of Array.from(element.children)) { + this.handleElement(child) + } + } + + if (element.tagName === "rect") { + this.drawRect(element) + } + + if (isTransformed) { + this.undoTransform() + } + } + + /** + * Helper function to calculate where the given point will end up. + * ALl the transforms of the parent elements are taking into account + * @param mapSpec + * @constructor + */ + static GetActualXY(mapSpec: SVGTSpanElement): { x: number, y: number } { + let runningM = SvgToPdfInternals.dummyDoc.unitMatrix + + let e: Element = mapSpec + do { + const m = SvgToPdfInternals.extractMatrix(e) + if (m !== null) { + runningM = SvgToPdfInternals.dummyDoc.matrixMult(runningM, m) + } + e = e.parentElement + } while (e !== null && e.parentElement != e) + + const x = SvgToPdfInternals.attrNumber(mapSpec, "x") + const y = SvgToPdfInternals.attrNumber(mapSpec, "y") + return runningM.applyToPoint({x, y}) + } +} + +export class SvgToPdf { + + private images: Record = {} + private rects: Record = {} + private readonly _svgRoots: SVGSVGElement[] = []; + private readonly _textSubstitutions: Record; + private readonly _beforePage: ((i: number) => void) | undefined; + public readonly _usedTranslations: Set = new Set() + private readonly _freeDivId: string | undefined; + + constructor(pages: string[], options?: { + freeDivId?: string, + textSubstitutions?: Record, beforePage?: (i: number) => void + }) { + this._textSubstitutions = options?.textSubstitutions ?? {}; + this._beforePage = options?.beforePage; + this._freeDivId = options?.freeDivId + const parser = new DOMParser(); + for (const page of pages) { + const xmlDoc = parser.parseFromString(page, "text/xml"); + const svgRoot = xmlDoc.getElementsByTagName("svg")[0]; + this._svgRoots.push(svgRoot) + } + + } + + private loadImage(element: Element): Promise { + const xlink = element.getAttribute("xlink:href") + let img = document.createElement("img") + + if (xlink.startsWith("data:image/svg+xml;")) { + const base64src = xlink; + let svgXml = atob(base64src.substring(base64src.indexOf(";base64,") + ";base64,".length)); + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(svgXml, "text/xml"); + const svgRoot = xmlDoc.getElementsByTagName("svg")[0]; + const svgWidthStr = svgRoot.getAttribute("width") + const svgHeightStr = svgRoot.getAttribute("height") + const svgWidth = parseFloat(svgWidthStr) + const svgHeight = parseFloat(svgHeightStr) + if (!svgWidthStr.endsWith("px")) { + svgRoot.setAttribute("width", svgWidth + "px") + } + if (!svgHeightStr.endsWith("px")) { + svgRoot.setAttribute("height", svgHeight + "px") + } + img.src = "data:image/svg+xml;base64," + btoa(svgRoot.outerHTML) + } else { + img.src = xlink + } + + this.images[xlink] = img + return new Promise((resolve) => { + img.onload = _ => { + resolve() + } + + }) + } + + + public async prepareElement(element: SVGSVGElement | Element, mapTextSpecs: SVGTSpanElement[]): Promise { + if (element.tagName === "rect") { + this.rects[element.id] = element; + } + + if (element.tagName === "image") { + await this.loadImage(element) + } + + if (element.tagName === "tspan" && element.childElementCount == 0) { + const specialValues = element.textContent.split(" ").filter(t => t.startsWith("$")) + for (let specialValue of specialValues) { + const translationMatch = specialValue.match(/\$([a-zA-Z0-9._-]+)(.*)/) + if (translationMatch !== null) { + this._usedTranslations.add(translationMatch[1]) + } + if (element.textContent.startsWith("$map(")) { + mapTextSpecs.push(element) + + } + } + } + + if (element.tagName === "g" || element.tagName === "text" || element.tagName === "tspan" || element.tagName === "defs") { + + for (let child of Array.from(element.children)) { + await this.prepareElement(child, mapTextSpecs) + } + } + + } + + private _isPrepared = false; + + public async Prepare() { + if (this._isPrepared) { + return + } + this._isPrepared = true; + const mapSpecs: SVGTSpanElement[] = [] + for (const svgRoot of this._svgRoots) { + for (let child of Array.from(svgRoot.children)) { + await this.prepareElement(child, mapSpecs) + } + } + + for (const mapSpec of mapSpecs) { + // Upper left point of the tspan + const {x, y} = SvgToPdfInternals.GetActualXY(mapSpec) + + let textElement: Element = mapSpec + // We recurse up to get the actual, full specification + while (textElement.tagName !== "text") { + textElement = textElement.parentElement + } + const spec = textElement.textContent + const match = spec.match(/\$map\(([^)]+)\)$/) + if (match === null) { + throw "Invalid mapspec:" + spec + } + const params = SvgToPdfInternals.parseCss(match[1], ",") + + let smallestRect: SVGRectElement = undefined + let smallestSurface: number = undefined; + // We iterate over all the rectangles and pick the smallest (by surface area) that contains the upper left point of the tspan + for (const id in this.rects) { + const rect = this.rects[id] + const rx = SvgToPdfInternals.attrNumber(rect, "x") + const ry = SvgToPdfInternals.attrNumber(rect, "y") + const w = SvgToPdfInternals.attrNumber(rect, "width") + const h = SvgToPdfInternals.attrNumber(rect, "height") + const inBounds = rx <= x && x <= rx + w && ry <= y && y <= ry + h + if (!inBounds) { + continue + } + const surface = w * h + if (smallestSurface === undefined || smallestSurface > surface) { + smallestSurface = surface + smallestRect = rect + } + + } + + if (smallestRect === undefined) { + throw "No rectangle found around " + spec + ". Draw a rectangle around it, the map will be projected on that one" + } + + const svgImage = document.createElement('image') + svgImage.setAttribute("x", smallestRect.getAttribute("x")) + svgImage.setAttribute("y", smallestRect.getAttribute("y")) + const width = SvgToPdfInternals.attrNumber(smallestRect, "width") + const height = SvgToPdfInternals.attrNumber(smallestRect, "height") + svgImage.setAttribute("width", "" + width) + svgImage.setAttribute("height", "" + height) + + let layout = AllKnownLayouts.allKnownLayouts.get(params["theme"]) + + if (layout === undefined) { + console.error("Could not show map with parameters", params) + throw "Theme not found:" + params["theme"] + ". Use theme: to define which theme to use. " + } + const zoom = Number(params["zoom"] ?? params["z"] ?? 14); + for (const l of layout.layers) { + l.minzoom = zoom + } + const state = new FeaturePipelineState(layout) + state.backgroundLayer.addCallbackAndRunD(l => console.log("baselayer is",l.id)) + state.locationControl.setData({ + zoom, + lat: Number(params["lat"] ?? 51.05016), + lon: Number(params["lon"] ?? 3.717842) + }) + + if (params["layers"] === "none") { + const fl = state.filteredLayers.data + for (const filteredLayer of fl) { + filteredLayer.isDisplayed.setData(false) + } + } + + for (const paramsKey in params) { + if (paramsKey.startsWith("layer-")) { + const layerName = paramsKey.substring("layer-".length) + const isDisplayed = params[paramsKey].toLowerCase().trim() === "true"; + console.log("Setting display status of ", layerName, "to", isDisplayed) + state.filteredLayers.data.find(l => l.layerDef.id === layerName).isDisplayed.setData( + isDisplayed + ) + } + } + + const pngCreator = new PngMapCreator( + state, + { + width, + height, + scaling: Number(params["scaling"] ?? 1.5), + divId: this._freeDivId + } + ) + const png = await pngCreator.CreatePng("image") + + svgImage.setAttribute('xlink:href', png) + smallestRect.parentElement.insertBefore(svgImage, smallestRect) + await this.prepareElement(svgImage, []) + smallestRect.setAttribute("style", "fill:#ff00ff00;fill-opacity:0;stroke:#000000;stroke-width:0.202542;stroke-linecap:round;stroke-opacity:1") + textElement.parentElement.removeChild(textElement) + } + } + + public async ConvertSvg(saveAs: string): Promise { + await this.Prepare() + const firstPage = this._svgRoots[0] + const width = SvgToPdfInternals.attrNumber(firstPage, "width") + const height = SvgToPdfInternals.attrNumber(firstPage, "height") + const mode = width > height ? "landscape" : "portrait" + + const doc = new jsPDF(mode) + const beforePage = this._beforePage ?? (_ => { + }); + const svgRoots = this._svgRoots; + doc.advancedAPI(advancedApi => { + const internal = new SvgToPdfInternals(advancedApi, this._textSubstitutions, this.images, this.rects); + for (let i = 0; i < this._svgRoots.length; i++) { + beforePage(i) + const svgRoot = svgRoots[i]; + for (let child of Array.from(svgRoot.children)) { + internal.handleElement(child) + } + if (i > 0) { + advancedApi.addPage() + } + } + }) + await doc.save(saveAs); + } + + +} diff --git a/langs/en.json b/langs/en.json index 44f5a6df1..8d3fe8229 100644 --- a/langs/en.json +++ b/langs/en.json @@ -39,6 +39,30 @@ "panelIntro": "

Your personal theme

Activate your favourite layers from all the official themes", "reload": "Reload the data" }, + "flyer": { + "callToAction": "Test it on mapcomplete.osm.be", + "frontParagraph": "MapComplete is an easy to use web application to collect geodata in OpenStreetMap, enabling collecting and managing relevant data in an open, crowdsourced and reusable way.\n\nNew categories and attributes can be added upon request.", + "license": { + "text": "The webversion is free to use, both for viewing and adding data.\nAdding data requires a free account on OpenStreetMap.org.\n\n MapComplete can tailored to your needs, with new map layers, new functionalities or styled to your organisation styleguide. We also have experience with starting campaigns to crowdsource geodata.\nContact pietervdvn@posteo.net for a quote.\n\nMapComplete is fully Open Source (GPL-licenses).\n\nData on OpenStreetMap is under the ODbL-license, which means all data can be reused for all purposes, as long as attribution is given and all (improvements to) the data are republished under the same license.\nSee osm.org/copyright for more details", + "title": "License and pricing" + }, + "mapcomplete": { + "intro": "MapComplete is a website which has {mapCount} interactive maps. Every single map allows to add or update information.", + "li0": "Communicate where POI are", + "li1": "Add new points and update information on existing points", + "li2": "View and add pictures", + "li3": "Add contact information and opening hours easily", + "li4": "See Wikipedia articles about the selected feature", + "li5": "See aerial imagery and map backgrounds", + "li6": "Can be placed in other websites as iFrame", + "li7": "Embedded within the OpenStreetMap-ecosystem, which has many tools available", + "title": "What is MapComplete?" + }, + "osm": "OpenStreetMap is an online map which can be edited and reused by anyone for any purpose - just like Wikipedia.\n\nIt is the biggest geospatial database in the world and is reused by thousands of applications and websites.", + "tagline": "Collect geodata easily with OpenStreetMap", + "title": "MapComplete.osm.be", + "whatIsOsm": "What is OpenStreetMap?" + }, "general": { "about": "Easily edit and add OpenStreetMap for a certain theme", "aboutMapcomplete": "

About

Use MapComplete to add OpenStreetMap info on a single theme. Answer questions, and within minutes your contributions are available everywhere. In most themes you can add pictures or even leave a review. The theme maintainer defines elements, questions and languages for it.

Find out more

MapComplete always offers the next step to learn more about OpenStreetMap.

  • When embedded in a website, the iframe links to a full-screen MapComplete.
  • The fullscreen version offers info about OpenStreetMap.
  • Viewing works without login, but editing requires an OSM account.
  • If you are not logged in, you are asked to do so
  • Once you answered a single question, you can add new features to the map
  • After a while, actual OSM-tags are shown, later linking to the wiki


Did you notice an issue? Do you have a feature request? Want to help translate? Head over to the source code or issue tracker.

Want to see your progress? Follow the edit count on OsmCha.

", diff --git a/langs/nl.json b/langs/nl.json index 1fcba15af..d8d4d0583 100644 --- a/langs/nl.json +++ b/langs/nl.json @@ -39,6 +39,30 @@ "panelIntro": "

Jouw persoonlijke thema

Activeer je favorite lagen van alle andere themas", "reload": "Herlaad de data" }, + "flyer": { + "callToAction": "Probeer het uit op mapcomplete.osm.be", + "frontParagraph": "MapComplete is een web-applicatie om OpenStreetMap-data te tonen en aan te passen op basis van thematische kaarten. Het maakt het mogelijk om open geodata te crowdsourcen en te managen op een makkelijke manier.\n\nNieuwe categorie�n en attributen kunnen op vraag worden toegevoegd.", + "license": { + "text": "De webversie is gratis te gebruiken, zowel voor het bekijken als voor het toevoegen van data.\nVoor het toevoegen van data is een gratis account op OpenStreetMap.org vereist.\n\nWil je een versie op maat? Wil je een versie in jullie huisstijl?\nWil je een nieuwe kaartlaag of functionaliteit? Wil je een crowdsourcing-campagne opzetten?\nNeem contact op met pietervdvn@posteo.net voor een offerte.\n\nMapComplete is volledig OpenSource (GPL-licentie).\n\nData op OpenStreetMap valt onder de ODbL-licentie. Data mag herbruikt worden voor alle doeleinden, mits bronvermelding en het openhouden van (verbeteringen aan) de data.\nZie osm.org/copyright voor alle details.", + "title": "Licentie and kostprijs" + }, + "mapcomplete": { + "intro": "MapComplete is een website met {mapCount} interactieve kaarten. Op iedere kaart kunnen gebruikers data zien en updaten.", + "li0": "Communiceer waar interessepunten zijn", + "li1": "Voeg nieuwe punten toe en update informatie van reeds bestaande punten", + "li2": "Bekijk en voeg foto's van interessepunten toe", + "li3": "Voeg eenvoudig contactgegevens en openingsuren toe", + "li4": "Bekijk het Wikipedia-artikel van interessepunten", + "li5": "Wissel tussen kaart- en luchtfoto's als achtergrond", + "li6": "Eenvoudig te embedden in een website als iFrame", + "li7": "Deel van het OpenStreetMap-ecosysteem waarbinnen honderden andere tools bestaan", + "title": "Wat is MapComplete?" + }, + "osm": "OpenStreetMap is een online kaart die door iedereen aangepast en herbruikt mag worden - net zoals Wikipedia.\n\nHet is de grootste geodatabank ter wereld en wordt herbruikt door miljoenen websites en applicaties.", + "tagline": "Verzamel geodata eenvoudig met OpenStreetMap", + "title": "MapComplete.osm.be", + "whatIsOsm": "Wat is OpenStreetMap?" + }, "general": { "about": "Bewerk en voeg data toe aan OpenStreetMap over een specifiek onderwerp op een gemakkelijke manier", "aboutMapcomplete": "

Over MapComplete

Met MapComplete kun je OpenStreetMap verrijken met informatie over een bepaald thema. Beantwoord enkele vragen, en binnen een paar minuten is jouw bijdrage wereldwijd beschikbaar! De maker van het thema bepaalt de elementen, vragen en taalversies voor het thema.

Ontdek meer

MapComplete biedt altijd de volgende stap naar meer OpenStreetMap:

  • Indien ingebed in een website linkt het iframe naar de volledige MapComplete
  • De volledige versie heeft uitleg over OpenStreetMap
  • Bekijken kan altijd, maar wijzigen vereist een OSM-account
  • Als je niet aangemeld bent, wordt je gevraagd dit te doen
  • Als je minstens één vraag hebt beantwoord, kan je ook elementen toevoegen
  • Heb je genoeg changesets, dan verschijnen de OSM-tags, nog later links naar de wiki

Merk je een bug of wil je een extra feature? Wil je helpen vertalen? Bezoek dan de broncode en issue tracker.

Wil je je vorderingen zien? Volg de edits op OsmCha.

", diff --git a/test.ts b/test.ts index 44eee345f..9f73c6bb9 100644 --- a/test.ts +++ b/test.ts @@ -1,426 +1,56 @@ -import {Utils} from "./Utils"; -import jsPDF, {Matrix} from "jspdf"; import "./assets/templates/Ubuntu-M-normal.js" import "./assets/templates/Ubuntu-L-normal.js" import "./assets/templates/UbuntuMono-B-bold.js" +import {AllKnownLayouts} from "./Customizations/AllKnownLayouts"; +import MinimapImplementation from "./UI/Base/MinimapImplementation"; +import {Utils} from "./Utils"; +import FeaturePipelineState from "./Logic/State/FeaturePipelineState"; +import Locale from "./UI/i18n/Locale"; +import {SvgToPdf} from "./Utils/svgToPdf"; -class SvgToPdfInternals { - private readonly doc: jsPDF; - private readonly matrices: Matrix[] = [] - private readonly matricesInverted: Matrix[] = [] - - private currentMatrix: Matrix; - private currentMatrixInverted: Matrix; - - private readonly _images: Record; - - constructor(advancedApi: jsPDF, images: Record) { - this.doc = advancedApi; - this._images = images; - this.currentMatrix = this.doc.unitMatrix; - this.currentMatrixInverted = this.doc.unitMatrix; - } - - private applyMatrices(): void { - let multiplied = this.doc.unitMatrix; - let multipliedInv = this.doc.unitMatrix; - for (const matrix of this.matrices) { - multiplied = this.doc.matrixMult(multiplied, matrix) - } - for (const matrix of this.matricesInverted) { - multipliedInv = this.doc.matrixMult(multiplied, matrix) - } - this.currentMatrix = multiplied - this.currentMatrixInverted = multipliedInv - } - - private addMatrix(m: Matrix) { - this.matrices.push(m) - this.matricesInverted.push(m.inversed()) - this.doc.setCurrentTransformationMatrix(m); - this.applyMatrices() - - } - - public setTransform(element: Element): boolean { - - const t = element.getAttribute("transform") - if (t === null) { - return false; - } - const scaleMatch = t.match(/scale\(([-0-9.]*)\)/) - if (scaleMatch !== null) { - const s = Number(scaleMatch[1]) - const m = this.doc.Matrix(1 / s, 0, 0, 1 / s, 0, 0) - this.addMatrix(m) - return true; - } - - const transformMatch = t.match(/matrix\(([-0-9.]*),([-0-9.]*),([-0-9.]*),([-0-9.]*),([-0-9.]*),([-0-9.]*)\)/) - if (transformMatch !== null) { - const vals = [1, 0, 0, 1, 0, 0] - const invVals = [1, 0, 0, 1, 0, 0] - for (let i = 0; i < 6; i++) { - const ti = Number(transformMatch[i + 1]) - if (ti == 0) { - vals[i] = 0 - } else { - invVals[i] = 1 / ti - vals[i] = ti - } - } - const m = this.doc.Matrix(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]) - this.addMatrix(m) - return true; - } - - return false; - } - - public undoTransform(): void { - this.matrices.pop() - const i = this.matricesInverted.pop() - this.doc.setCurrentTransformationMatrix(i) - this.applyMatrices() - - } - - private static parseCss(styleContent: string): Record { - if (styleContent === undefined || styleContent === null) { - return {} - } - const r: Record = {} - - for (const rule of styleContent.split(";")) { - const [k, v] = rule.split(":").map(x => x.trim()) - r[k] = v - } - - return r - }; - - private drawRect(element: Element) { - const x = Number(element.getAttribute("x")) - const y = Number(element.getAttribute("y")) - const width = Number(element.getAttribute("width")) - const height = Number(element.getAttribute("height")) - const style = element.getAttribute("style") - - const css = SvgToPdfInternals.parseCss(style) - this.doc.setDrawColor(css["stroke-color"] ?? "black") - this.doc.setFillColor(css["fill"] ?? "black") - this.doc.rect(x, y, width, height, "F") - return - } - - private static attr(element: Element, name: string, recurseup: boolean = true): string { - const a = element.getAttribute(name) - if (a !== null && a !== undefined) { - return a - } - if (recurseup && element.parentElement !== undefined && element.parentElement !== element) { - return SvgToPdfInternals.attr(element.parentElement, name, recurseup) - } - return undefined - } - - /** - * Reads the 'style'-element recursively - * @param element - * @private - */ - private static css(element: Element): Record { - - if (element.parentElement == undefined || element.parentElement == element) { - return SvgToPdfInternals.parseCss(element.getAttribute("style")) - } - - const css = SvgToPdfInternals.css(element.parentElement); - const style = element.getAttribute("style") - if (style === undefined || style == null) { - return css - } - for (const rule of style.split(";")) { - const [k, v] = rule.split(":").map(x => x.trim()) - css[k] = v - } - return css - - } - - private static attrNumber(element: Element, name: string, recurseup: boolean = true): number { - const a = SvgToPdfInternals.attr(element, name, recurseup) - const n = Number(a) - if (!isNaN(n)) { - return n - } - return undefined - } - - private drawTspan(tspan: Element) { - if (tspan.textContent == "") { - return - } - const x = SvgToPdfInternals.attrNumber(tspan, "x") - const y = SvgToPdfInternals.attrNumber(tspan, "y") - - const css = SvgToPdfInternals.css(tspan) - const w = SvgToPdfInternals.attrNumber(tspan, "width") - - let fontFamily = css["font-family"] ?? "Ubuntu"; - if (fontFamily === "sans-serif") { - fontFamily = "Ubuntu" - } - - let fontWeight = css["font-weight"] ?? "normal"; - this.doc.setFont(fontFamily, fontWeight) - - - const fontColor = css["fill"] - if (fontColor) { - this.doc.setTextColor(fontColor) - } else { - this.doc.setTextColor("black") - } - let fontsize = parseFloat(css["font-size"]) - - console.log("Fontsize is ", fontsize, "for", tspan.textContent, this.currentMatrixInverted) - this.doc.setFontSize(fontsize * 2.5) - this.doc.text(tspan.textContent, x, y, { - maxWidth: w, - }, this.currentMatrix) - } - - private drawSvgViaCanvas(element: Element): void { - const x = SvgToPdfInternals.attrNumber(element, "x") - const y = SvgToPdfInternals.attrNumber(element, "y") - const width = SvgToPdfInternals.attrNumber(element, "width") - const height = SvgToPdfInternals.attrNumber(element, "height") - const base64src = SvgToPdfInternals.attr(element, "xlink:href") - const svgXml = atob(base64src.substring(base64src.indexOf(";base64,") + ";base64,".length)); - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(svgXml, "text/xml"); - const svgRoot = xmlDoc.getElementsByTagName("svg")[0]; - const svgWidth = SvgToPdfInternals.attrNumber(svgRoot, "width") - const svgHeight = SvgToPdfInternals.attrNumber(svgRoot, "height") - - - let img = this._images[base64src] - // This is an svg image, we use the canvas to convert it to a png - const canvas = document.createElement("canvas") - const ctx = canvas.getContext("2d") - - canvas.width = svgWidth - canvas.height = svgHeight - img.style.width = `${(svgWidth)}px` - img.style.height = `${(svgHeight)}px` - - ctx.drawImage(img, 0, 0, svgWidth, svgHeight) - const base64img = canvas.toDataURL("image/png") - - this.addMatrix(this.doc.Matrix(width / svgWidth, 0, 0, height / svgHeight, 0, 0)) - const p = this.currentMatrixInverted.applyToPoint({x, y}) - this.doc.addImage(base64img, "png", p.x * svgWidth / width, p.y * svgHeight / height, svgWidth, svgHeight) - this.undoTransform() - } - - private drawImage(element: Element): void { - const href = SvgToPdfInternals.attr(element, "xlink:href") - if (href.endsWith('svg') || href.startsWith("data:image/svg")) { - this.drawSvgViaCanvas(element); - } - } - - public handleElement(element: SVGSVGElement | Element): void { - const isTransformed = this.setTransform(element) - if (element.tagName === "tspan") { - if(element.childElementCount == 0){ - this.drawTspan(element) - }else{ - for (let child of Array.from(element.children)) { - console.log("Handling tspan child") - this.handleElement(child) - } - } - } - - if (element.tagName === "image") { - this.drawImage(element) - } - - if (element.tagName === "g" || element.tagName === "text" ) { - - for (let child of Array.from(element.children)) { - this.handleElement(child) - } - } - - if (element.tagName === "rect") { - this.drawRect(element) - } - - if (isTransformed) { - this.undoTransform() - } - } - -} - -class SvgToPdf { - - private readonly doc - private images: Record = {} - - constructor(mode: 'landscape' | 'portrait' = 'landscape') { - this.doc = new jsPDF(mode) - } - - private loadImage(element: Element): Promise { - const base64src = element.getAttribute("xlink:href") - let svgXml = atob(base64src.substring(base64src.indexOf(";base64,") + ";base64,".length)); - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(svgXml, "text/xml"); - const svgRoot = xmlDoc.getElementsByTagName("svg")[0]; - const svgWidthStr = svgRoot.getAttribute("width") - const svgHeightStr = svgRoot.getAttribute("height") - const svgWidth = parseFloat(svgWidthStr) - const svgHeight = parseFloat(svgHeightStr) - if (!svgWidthStr.endsWith("px")) { - svgRoot.setAttribute("width", svgWidth + "px") - } - if (!svgHeightStr.endsWith("px")) { - svgRoot.setAttribute("height", svgHeight + "px") - } - - let img = document.createElement("img") - img.src = "data:image/svg+xml;base64," + btoa(svgRoot.outerHTML) - this.images[base64src] = img - return new Promise((resolve) => { - img.onload = _ => { - resolve() - } - - }) - } - - - public async prepareElement(element: SVGSVGElement | Element): Promise { - if (element.tagName === "tspan") { - // this.drawTspan(element) - } - - if (element.tagName === "image") { - await this.loadImage(element) - } - - if (element.tagName === "g" || element.tagName === "text" || element.tagName === "tspan") { - - for (let child of Array.from(element.children)) { - await this.prepareElement(child) - } - } - - } - - public async ConvertSvg(svgSource: string): Promise { - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(svgSource, "text/xml"); - const svgRoot = xmlDoc.getElementsByTagName("svg")[0]; - for (let child of Array.from(svgRoot.children)) { - await this.prepareElement(child) - } - - this.doc.advancedAPI(advancedApi => { - this.doc.setCurrentTransformationMatrix(this.doc.unitMatrix) - const internal = new SvgToPdfInternals(advancedApi, this.images); - for (let child of Array.from(svgRoot.children)) { - internal.handleElement(child) - } - - - }) - await this.doc.save(`Test_flyer.pdf`); - } - - -} - +MinimapImplementation.initialize() async function main() { + const layoutToUse = AllKnownLayouts.allKnownLayouts.get("cyclofix") + const svg = await Utils.download(window.location.protocol + "//" + window.location.host + "/assets/templates/MapComplete-flyer.svg") - await new SvgToPdf().ConvertSvg(svg) + const svgBack = await Utils.download(window.location.protocol + "//" + window.location.host + "/assets/templates/MapComplete-flyer.back.svg") + Locale.language.setData("en") /* - const image = await minimap.TakeScreenshot() - // @ts-ignore - doc.addImage(image, 'PNG', 0, 0, this.mapW, this.mapH); + await new SvgToPdf([svg], { + state, + textSubstitutions: { + mapCount: "" + Array.from(AllKnownLayouts.allKnownLayouts.values()).filter(th => !th.hideFromOverview).length + } + }).ConvertSvg("flyer_en.pdf") + //*/ - doc.setDrawColor(255, 255, 255) - doc.setFillColor(255, 255, 255) - doc.roundedRect(12, 10, 145, 25, 5, 5, 'FD') - - doc.setFontSize(20) - doc.textWithLink(layout.title.txt, 40, 18.5, { - maxWidth: 125, - url: window.location.href - }) - doc.setFontSize(10) - doc.text(t.generatedWith.txt, 40, 23, { - maxWidth: 125 - }) - const backgroundLayer: BaseLayer = State.state.backgroundLayer.data - const attribution = new FixedUiElement(backgroundLayer.layer().getAttribution() ?? backgroundLayer.name).ConstructElement().textContent - doc.textWithLink(t.attr.txt, 40, 26.5, { - maxWidth: 125, - url: "https://www.openstreetmap.org/copyright" - }) - - doc.text(t.attrBackground.Subs({ - background: attribution - }).txt, 40, 30) - - let date = new Date().toISOString().substr(0, 16) - - doc.setFontSize(7) - doc.text(t.versionInfo.Subs({ - version: Constants.vNumber, - date: date - }).txt, 40, 34, { - maxWidth: 125 - }) - - // Add the logo of the layout - let img = document.createElement('img'); - const imgSource = layout.icon - const imgType = imgSource.substr(imgSource.lastIndexOf(".") + 1); - img.src = imgSource - if (imgType.toLowerCase() === "svg") { - new FixedUiElement("").AttachTo(this.freeDivId) - - // This is an svg image, we use the canvas to convert it to a png - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d'); - canvas.width = 500 - canvas.height = 500 - img.style.width = "100%" - img.style.height = "100%" - ctx.drawImage(img, 0, 0, 500, 500); - const base64img = canvas.toDataURL("image/png") - doc.addImage(base64img, 'png', 15, 12, 20, 20); - - } else { - try { - doc.addImage(img, imgType, 15, 12, 20, 20); - } catch (e) { - console.error(e) - } + Locale.language.setData("en") + const svgToPdf = new SvgToPdf([svgBack], { + freeDivId: "extradiv", + textSubstitutions: { + mapCount: "" + Array.from(AllKnownLayouts.allKnownLayouts.values()).filter(th => !th.hideFromOverview).length } + }) + await svgToPdf.Prepare() + console.log("Used translations", svgToPdf._usedTranslations) + await svgToPdf.ConvertSvg("flyer_nl.pdf") + /* +Locale.language.setData("en") +await new SvgToPdf([svgBack], { + textSubstitutions: { + mapCount: "" + Array.from(AllKnownLayouts.allKnownLayouts.values()).filter(th => !th.hideFromOverview).length + } +}).ConvertSvg("flyer_en.pdf") - doc.save(`MapComplete_${layout.title.txt}_${date}.pdf`); - - this.isRunning.setData(false) - //*/ +Locale.language.setData("nl") +await new SvgToPdf([svgBack], { + textSubstitutions: { + mapCount: "" + Array.from(AllKnownLayouts.allKnownLayouts.values()).filter(th => !th.hideFromOverview).length + } +}).ConvertSvg("flyer_nl.pdf")*/ } main().then(() => console.log("Done!"))