More work on making flyers

This commit is contained in:
pietervdvn 2022-09-12 20:14:03 +02:00
parent 9d753ec7c0
commit b9d5a1edff
8 changed files with 831 additions and 418 deletions

View file

@ -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<BaseLayer>
@ -24,7 +25,10 @@ export interface MinimapObj {
installBounds(factor: number | BBox, showRange?: boolean): void
TakeScreenshot(): Promise<any>
TakeScreenshot(format): Promise<string>
TakeScreenshot(format: "image"): Promise<string>
TakeScreenshot(format:"blob"): Promise<Blob>
TakeScreenshot(format?: "image" | "blob"): Promise<string | Blob>
}
export default class Minimap {

View file

@ -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<string> ;
public async TakeScreenshot(format: "image"): Promise<string> ;
public async TakeScreenshot(format: "blob"): Promise<Blob> ;
public async TakeScreenshot(format: "image" | "blob"): Promise<string | Blob> ;
public async TakeScreenshot(format: "image" | "blob" = "image"): Promise<string | Blob> {
const screenshotter = new SimpleMapScreenshoter()
screenshotter.addTo(this.leafletMap.data)
return await screenshotter.takeScreen("image")
const result = <any> await screenshotter.takeScreen((<any> 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 {

View file

@ -32,7 +32,7 @@ export default class LocationInput
public readonly leafletMap: UIEventSource<any>
public readonly bounds
public readonly location
private _centerLocation: UIEventSource<Loc>
private readonly _centerLocation: UIEventSource<Loc>
private readonly mapBackground: UIEventSource<BaseLayer>
/**
* The features to which the input should be snapped
@ -177,10 +177,6 @@ export default class LocationInput
this.map.installBounds(factor, showRange)
}
TakeScreenshot(): Promise<any> {
return this.map.TakeScreenshot()
}
protected InnerConstructElement(): HTMLElement {
try {
const self = this
@ -270,4 +266,8 @@ export default class LocationInput
.ConstructElement()
}
}
TakeScreenshot(): Promise<string> {
return this.map.TakeScreenshot()
}
}

102
Utils/pngMapCreator.ts Normal file
View file

@ -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<MinimapImplementation> {
const state = this._state;
const options = this._options
return new Promise(resolve => {
const minimap = Minimap.createMiniMap({
location: new UIEventSource<Loc>(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(<MinimapImplementation>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<string > ;
public async CreatePng(format: "blob"): Promise<Blob> ;
public async CreatePng(format: "image" | "blob"): Promise<string | Blob>;
public async CreatePng(format: "image" | "blob"): Promise<string | Blob> {
// 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<string | Blob>(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)
})
}
}

613
Utils/svgToPdf.ts Normal file
View file

@ -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<string, string>;
private readonly matrices: Matrix[] = []
private readonly matricesInverted: Matrix[] = []
private currentMatrix: Matrix;
private currentMatrixInverted: Matrix;
private readonly _images: Record<string, HTMLImageElement>;
private readonly _rects: Record<string, SVGRectElement>;
constructor(advancedApi: jsPDF, textSubstitutions: Record<string, string>, images: Record<string, HTMLImageElement>, rects: Record<string, SVGRectElement>) {
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<string, string> {
if (styleContent === undefined || styleContent === null) {
return {}
}
const r: Record<string, string> = {}
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<string, string> {
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 (<TypedTranslation<any>>t).Subs(this.textSubstitutions).txt + rest
} else {
return (<Translation>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<string, HTMLImageElement> = {}
private rects: Record<string, SVGRectElement> = {}
private readonly _svgRoots: SVGSVGElement[] = [];
private readonly _textSubstitutions: Record<string, string>;
private readonly _beforePage: ((i: number) => void) | undefined;
public readonly _usedTranslations: Set<string> = new Set<string>()
private readonly _freeDivId: string | undefined;
constructor(pages: string[], options?: {
freeDivId?: string,
textSubstitutions?: Record<string, string>, 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<void> {
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<void> {
if (element.tagName === "rect") {
this.rects[element.id] = <SVGRectElement>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(<any>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(<any>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<void> {
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(<any>child)
}
if (i > 0) {
advancedApi.addPage()
}
}
})
await doc.save(saveAs);
}
}

View file

@ -39,6 +39,30 @@
"panelIntro": "<h3>Your personal theme</h3>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": "<h3>About</h3><p>Use MapComplete to add OpenStreetMap info on a <b>single theme.</b> Answer questions, and within minutes your contributions are available everywhere. In most themes you can add pictures or even leave a review. The <b>theme maintainer</b> defines elements, questions and languages for it.</p><h3>Find out more</h3><p>MapComplete always <b>offers the next step</b> to learn more about OpenStreetMap.<ul><li>When embedded in a website, the iframe links to a full-screen MapComplete.</li><li>The fullscreen version offers info about OpenStreetMap.</li><li>Viewing works without login, but editing requires an OSM account.</li><li>If you are not logged in, you are asked to do so</li><li>Once you answered a single question, you can add new features to the map</li><li>After a while, actual OSM-tags are shown, later linking to the wiki</li></ul></p><br/><p>Did you notice <b>an issue</b>? Do you have a <b>feature request</b>? Want to <b>help translate</b>? Head over to <a href='https://github.com/pietervdvn/MapComplete' target='_blank'>the source code</a> or <a href='https://github.com/pietervdvn/MapComplete/issues' target='_blank'>issue tracker.</a> </p><p> Want to see <b>your progress</b>? Follow the edit count on <a href='{osmcha_link}' target='_blank' >OsmCha</a>.</p>",

View file

@ -39,6 +39,30 @@
"panelIntro": "<h3>Jouw persoonlijke thema</h3>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<69>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": "<h3>Over MapComplete</h3><p>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 <b>maker van het thema</b> bepaalt de elementen, vragen en taalversies voor het thema.</p><h3>Ontdek meer</h3><p>MapComplete <b>biedt altijd de volgende stap</b> naar meer OpenStreetMap:</p><ul><li>Indien ingebed in een website linkt het iframe naar de volledige MapComplete</li><li>De volledige versie heeft uitleg over OpenStreetMap</li><li>Bekijken kan altijd, maar wijzigen vereist een OSM-account</li><li>Als je niet aangemeld bent, wordt je gevraagd dit te doen</li><li>Als je minstens één vraag hebt beantwoord, kan je ook elementen toevoegen</li><li>Heb je genoeg changesets, dan verschijnen de OSM-tags, nog later links naar de wiki</li></ul><p></p><p>Merk je <b>een bug</b> of wil je een <b>extra feature</b>? Wil je <b>helpen vertalen</b>? Bezoek dan de <a href=\"https://github.com/pietervdvn/MapComplete\" target=\"_blank\">broncode</a> en <a href=\"https://github.com/pietervdvn/MapComplete/issues\" target=\"_blank\">issue tracker</a>. </p><p></p>Wil je <b>je vorderingen</b> zien? Volg de edits <a href=\"{osmcha_link}\" target=\"_blank\">op OsmCha</a>.<p></p>",

450
test.ts
View file

@ -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<string, HTMLImageElement>;
constructor(advancedApi: jsPDF, images: Record<string, HTMLImageElement>) {
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<string, string> {
if (styleContent === undefined || styleContent === null) {
return {}
}
const r: Record<string, string> = {}
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<string, string> {
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<string, HTMLImageElement> = {}
constructor(mode: 'landscape' | 'portrait' = 'landscape') {
this.doc = new jsPDF(mode)
}
private loadImage(element: Element): Promise<void> {
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<void> {
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<void> {
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(<any>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(<any>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!"))