Fix: improve PDF-output functionality

This commit is contained in:
Pieter Vander Vennet 2023-06-07 00:14:20 +02:00
parent c6283ac720
commit 215286a5af
22 changed files with 363 additions and 221 deletions

View file

@ -43,12 +43,13 @@ export class PngMapCreator {
const l = settings.location.data
document.getElementById(freeComponentId).appendChild(div)
const pixelRatio = 4
const mapElem = new MlMap({
container: div.id,
style: AvailableRasterLayers.maplibre.properties.url,
center: [l.lon, l.lat],
zoom: settings.zoom.data,
pixelRatio: 6
pixelRatio
});
const map = new UIEventSource<MlMap>(mapElem)
@ -71,11 +72,11 @@ export class PngMapCreator {
await Utils.waitFor(250)
}
// Some extra buffer...
setState("One second pause to make sure all images are loaded...")
await Utils.waitFor(1000)
setState("Exporting png")
console.log("Loading for", this._state.layout.id, "is done")
const png = await mla.exportAsPng(6)
return png
const dpiFactor = 1
setState("Exporting png (" + this._options.width + "mm * " + this._options.height + "mm , dpiFactor:" + dpiFactor + ", maplibre-canvas-pixelratio: " + pixelRatio + ")")
return await mla.exportAsPng(dpiFactor)
} finally {
div.parentElement.removeChild(div)
}

View file

@ -9,7 +9,6 @@ import {makeAbsolute, parseSVG} from "svg-path-parser"
import Translations from "../UI/i18n/Translations"
import {Utils} from "../Utils"
import Constants from "../Models/Constants"
import Hash from "../Logic/Web/Hash"
import ThemeViewState from "../Models/ThemeViewState"
import {Store, UIEventSource} from "../Logic/UIEventSource"
@ -22,19 +21,17 @@ class SvgToPdfInternals {
private currentMatrix: Matrix
private currentMatrixInverted: Matrix
private readonly _images: Record<string, HTMLImageElement>
private readonly _rects: Record<string, SVGRectElement>
private readonly extractTranslation: (string) => string
private readonly page: SvgToPdfPage;
private readonly usedRectangles = new Set<string>()
constructor(
advancedApi: jsPDF,
images: Record<string, HTMLImageElement>,
rects: Record<string, SVGRectElement>,
page: SvgToPdfPage,
extractTranslation: (string) => string
) {
this.page = page;
this.doc = advancedApi
this._images = images
this._rects = rects
this.extractTranslation = (s) => extractTranslation(s)?.replace(/&nbsp;/g, " ")
this.currentMatrix = this.doc.unitMatrix
this.currentMatrixInverted = this.doc.unitMatrix
@ -55,7 +52,6 @@ class SvgToPdfInternals {
if (translateMatch !== null) {
const dx = Number(translateMatch[1])
const dy = Number(translateMatch[2])
console.log("Translating", dx, dy)
return SvgToPdfInternals.dummyDoc.Matrix(1, 0, 0, 1, dx, dy)
}
@ -211,6 +207,7 @@ class SvgToPdfInternals {
public handleElement(element: SVGSVGElement | Element): void {
const isTransformed = this.setTransform(element)
this.page.status.set("Handling element "+element.tagName+" "+element.id)
try {
if (element.tagName === "tspan") {
if (element.childElementCount == 0) {
@ -237,7 +234,9 @@ class SvgToPdfInternals {
}
if (element.tagName === "rect") {
this.drawRect(<any>element)
if (!this.usedRectangles.has(element.id)) {
this.drawRect(<SVGRectElement>element)
}
}
if (element.tagName === "circle") {
@ -267,7 +266,6 @@ class SvgToPdfInternals {
opacity = Number(css["fill-opacity"])
this.doc.setGState(this.doc.GState({opacity: opacity}))
}
console.log("Fill color is:", color, opacity)
this.doc.setFillColor(color)
this.doc.roundedRect(x, y, width, height, rx, ry, "F")
@ -308,8 +306,46 @@ class SvgToPdfInternals {
if (txt == "") {
return
}
const x = SvgToPdfInternals.attrNumber(tspan, "x")
const y = SvgToPdfInternals.attrNumber(tspan, "y")
let x = SvgToPdfInternals.attrNumber(tspan, "x")
let y = SvgToPdfInternals.attrNumber(tspan, "y")
const m = SvgToPdfInternals.extractMatrix(tspan.parentElement)
const p = m?.inversed()?.applyToPoint({x, y})
x = p?.x ?? x
y = p?.y ?? y
const imageMatch = txt.match(/^\$img\(([^)]*)\)$/)
if (imageMatch) {
// We want to draw a special image
const [_, key] = imageMatch
console.log("Creating image with key", key, "searching rect in", x, y)
const rectangle: SVGRectElement = this.page.findSmallestRectContaining(x, y, false)
console.log("Got rect", rectangle)
let w = SvgToPdfInternals.attrNumber(rectangle, "width")
let h = SvgToPdfInternals.attrNumber(rectangle, "height")
x = SvgToPdfInternals.attrNumber(rectangle, "x")
y = SvgToPdfInternals.attrNumber(rectangle, "y")
// Actually, dots per mm, not dots per inch ;)
let dpi = 60
const img = this.page.options.createImage(key, dpi * w + "px", dpi * h + "px")
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
canvas.width = w * dpi
canvas.height = h * dpi
img.style.width = `${w * dpi}px`
img.style.height = `${h * dpi}px`
ctx.drawImage(img, 0, 0, w * dpi, h * dpi)
const base64img = canvas.toDataURL("image/png")
// Don't ask me why this magicFactor transformation is needed - but it works
const magicFactor = 3.8
this.addMatrix(this.doc.Matrix(1 / magicFactor, 0, 0, 1 / magicFactor, 0, 0))
this.doc.addImage(base64img, "png", x, y, w, h)
this.undoTransform()
this.usedRectangles.add(rectangle.id)
return
}
let maxWidth: number = undefined
let maxHeight: number = undefined
@ -319,9 +355,11 @@ class SvgToPdfInternals {
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)
maxHeight = SvgToPdfInternals.attrNumber(rect, "height", false)
const rect = this.page.rects[rectId]?.rect
if (rect) {
maxWidth = SvgToPdfInternals.attrNumber(rect, "width", false)
maxHeight = SvgToPdfInternals.attrNumber(rect, "height", false)
}
}
}
@ -375,7 +413,6 @@ class SvgToPdfInternals {
const list = text.match(/\$list\(([a-zA-Z0-9_.-]+)\)/)
if (list) {
const key = list[1]
console.log("Generating a list with key" + key)
let r = this.extractTranslation("$" + key + "0")
let i = 0
result += "\n"
@ -395,13 +432,15 @@ class SvgToPdfInternals {
addSpace = true
}
}
const options = {}
if (maxWidth) {
options["maxWidth"] = maxWidth
}
this.doc.text(
result,
x,
y,
{
maxWidth,
},
options,
this.currentMatrix
)
}
@ -409,8 +448,8 @@ class SvgToPdfInternals {
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 width = SvgToPdfInternals.attrNumber(element, "width")
const base64src = SvgToPdfInternals.attr(element, "xlink:href")
const svgXml = atob(base64src.substring(base64src.indexOf(";base64,") + ";base64,".length))
const parser = new DOMParser()
@ -419,7 +458,7 @@ class SvgToPdfInternals {
const svgWidth = SvgToPdfInternals.attrNumber(svgRoot, "width")
const svgHeight = SvgToPdfInternals.attrNumber(svgRoot, "height")
let img = this._images[base64src]
let img = this.page.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")
@ -530,25 +569,29 @@ export interface SvgToPdfOptions {
* Override all the maps to generate with this map
*/
state?: ThemeViewState,
createImage(key: string, width: string, height: string): HTMLImageElement
}
class SvgToPdfPage {
public readonly _svgRoot: SVGSVGElement
private images: Record<string, HTMLImageElement> = {}
private rects: Record<string, SVGRectElement> = {}
images: Record<string, HTMLImageElement> = {}
rects: Record<string, { rect: SVGRectElement, isInDef: boolean }> = {}
readonly options: SvgToPdfOptions
private readonly importedTranslations: Record<string, string> = {}
private readonly layerTranslations: Record<string, Record<string, any>> = {}
private readonly options: SvgToPdfOptions
/**
* Small indicator for humans
* @private
*/
private readonly _state: UIEventSource<string>
private _isPrepared = false
public readonly status: UIEventSource<string>;
constructor(page: string, state: UIEventSource<string>, options: SvgToPdfOptions) {
constructor(page: string, state: UIEventSource<string>, options: SvgToPdfOptions, status: UIEventSource<string>) {
this._state = state
this.options = options
this.status = status;
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(page, "image/svg+xml")
this._svgRoot = xmlDoc.getElementsByTagName("svg")[0]
@ -567,7 +610,6 @@ class SvgToPdfPage {
(t) => t.textContent
)
const translations = new Set<string>()
console.log("Extracting translations, contents are", textContents)
for (const tc of textContents) {
const parts = tc.split(" ").filter((p) => p.startsWith("$") && p.indexOf("(") < 0)
for (let part of parts) {
@ -581,21 +623,19 @@ class SvgToPdfPage {
}
}
}
console.log("Translations keys are", translations)
return translations
}
/**
* Does some preparatory work, most importantly gathering the map specifications into parameter `mapTextSpecs` and substituting translations
* @param element
* @param mapTextSpecs
*/
public async prepareElement(
element: SVGSVGElement | Element,
mapTextSpecs: SVGTSpanElement[]
mapTextSpecs: SVGTSpanElement[],
inDefs: boolean
): Promise<void> {
if (element.tagName === "rect") {
this.rects[element.id] = <SVGRectElement>element
this.rects[element.id] = {rect: <SVGRectElement>element, isInDef: inDefs}
}
if (element.tagName === "image") {
await this.loadImage(element)
@ -612,17 +652,13 @@ class SvgToPdfPage {
const [, pathRaw, as] = importMatch
this.importedTranslations[as] = pathRaw
}
const setPropertyMatch = element.textContent.match(
/\$set\(([a-zA-Z-_0-9.?:]+),(.+)\)/
)
if (setPropertyMatch) {
this.options.textSubstitutions[setPropertyMatch[1].trim()] =
setPropertyMatch[2].trim()
console.log(
"Setting a property:",
setPropertyMatch,
this.options.textSubstitutions
)
}
if (element.textContent.startsWith("$map(")) {
@ -638,7 +674,7 @@ class SvgToPdfPage {
element.tagName === "defs"
) {
for (let child of Array.from(element.children)) {
await this.prepareElement(child, mapTextSpecs)
await this.prepareElement(child, mapTextSpecs, inDefs || element.tagName === "defs")
}
}
}
@ -667,16 +703,11 @@ class SvgToPdfPage {
this._isPrepared = true
const mapSpecs: SVGTSpanElement[] = []
for (let child of Array.from(this._svgRoot.children)) {
await this.prepareElement(<any>child, mapSpecs)
await this.prepareElement(<any>child, mapSpecs, child.tagName === "defs")
}
for (const mapSpec of mapSpecs) {
try {
await this.prepareMap(mapSpec, !this.options?.disableDataLoading)
} catch (e) {
console.error("Couldn't prepare a map:", e)
}
await this.prepareMap(mapSpec, !this.options?.disableDataLoading)
}
}
@ -689,12 +720,13 @@ class SvgToPdfPage {
this.options.beforePage(i)
}
const self = this
const internal = new SvgToPdfInternals(advancedApi, this.images, this.rects, (key) =>
self.extractTranslation(key, language)
const internal = new SvgToPdfInternals(advancedApi, this, (key) =>
self.extractTranslation(key, language)
)
for (let child of Array.from(this._svgRoot.children)) {
internal.handleElement(<any>child)
}
}
extractTranslation(text: string, language: string, strict: boolean = false) {
@ -753,6 +785,33 @@ class SvgToPdfPage {
}
}
public findSmallestRectContaining(x: number, y: number, shouldBeInDefinitionSection: boolean) {
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, isInDef} = this.rects[id]
if (shouldBeInDefinitionSection !== isInDef) {
continue
}
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
}
}
return smallestRect
}
private loadImage(element: Element | string): Promise<void> {
const xlink = typeof element === "string" ? element : element.getAttribute("xlink:href")
let img = document.createElement("img")
@ -805,27 +864,7 @@ class SvgToPdfPage {
}
const spec = textElement.textContent
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) {
console.log("Not in bounds: rectangle", id)
continue
}
const surface = w * h
if (smallestSurface === undefined || smallestSurface > surface) {
smallestSurface = surface
smallestRect = rect
}
}
const smallestRect = this.findSmallestRectContaining(x, y, false)
if (smallestRect === undefined) {
throw (
"No rectangle found around " +
@ -833,7 +872,6 @@ class SvgToPdfPage {
". 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"))
@ -880,8 +918,6 @@ class SvgToPdfPage {
}
const zoom = Number(params["zoom"] ?? params["z"] ?? 14)
Hash.hash.setData(undefined)
// QueryParameters.ClearAll()
const state = new ThemeViewState(layout)
state.mapProperties.location.setData({
lat: this.options?.overrideLocation?.lat ?? Number(params["lat"] ?? 51.05016),
@ -889,8 +925,6 @@ class SvgToPdfPage {
})
state.mapProperties.zoom.setData(zoom)
console.log("Params are", params, params["layers"] === "none")
const fl = Array.from(state.layerState.filteredLayers.values())
for (const filteredLayer of fl) {
if (params["layer-" + filteredLayer.layerDef.id] !== undefined) {
@ -937,7 +971,6 @@ class SvgToPdfPage {
}
}
}
console.log("Creating a map width ", width, height, params.scalingFactor)
const pngCreator = new PngMapCreator(state, {
width: 4 * width,
height: 4 * height,
@ -950,7 +983,7 @@ class SvgToPdfPage {
svgImage.setAttribute("xlink:href", await SvgToPdfPage.blobToBase64(png))
smallestRect.parentElement.insertBefore(svgImage, smallestRect)
await this.prepareElement(svgImage, [])
await this.prepareElement(svgImage, [], false)
const smallestRectCss = SvgToPdfInternals.parseCss(smallestRect.getAttribute("style"))
smallestRectCss["fill-opacity"] = "0"
smallestRect.setAttribute(
@ -964,36 +997,51 @@ class SvgToPdfPage {
}
}
export interface PdfTemplateInfo{ pages: string[];
description: string | Translation;
format: "a3" | "a4" | "a2",
orientation: "portrait" | "landscape"
isPublic: boolean }
export class SvgToPdf {
public static readonly templates: Record<
"flyer_a4" | "poster_a3" | "poster_a2" | "current_view_a4" | "current_view_a3",
{ pages: string[]; description: string | Translation; isPublic: boolean }
PdfTemplateInfo
> = {
flyer_a4: {
pages: [
"./assets/templates/MapComplete-flyer.svg",
"./assets/templates/MapComplete-flyer.back.svg",
],
format: "a4",
orientation: "landscape",
description: Translations.t.flyer.description,
isPublic: false
},
poster_a3: {
format: "a3",
orientation: "portrait",
pages: ["./assets/templates/MapComplete-poster-a3.svg"],
description: "A basic A3 poster (similar to the flyer)",
isPublic: false
},
poster_a2: {
format: "a2",
orientation: "portrait",
pages: ["./assets/templates/MapComplete-poster-a2.svg"],
description: "A basic A2 poster (similar to the flyer); scaled up from the A3 poster",
isPublic: false
},
current_view_a4: {
format: "a4",
orientation: "landscape",
pages: ["./assets/templates/CurrentMapWithHeaderA4.svg"],
description: Translations.t.general.download.pdf.current_view_a4,
isPublic: true
},
current_view_a3: {
format: "a3",
orientation: "portrait",
pages: ["./assets/templates/CurrentMapWithHeaderA3.svg"],
description: Translations.t.general.download.pdf.current_view_a3,
isPublic: true
@ -1004,7 +1052,7 @@ export class SvgToPdf {
private readonly _title: string
private readonly _pages: SvgToPdfPage[]
constructor(title: string, pages: string[], options) {
constructor(title: string, pages: string[], options: SvgToPdfOptions) {
this._title = title
options.textSubstitutions = options.textSubstitutions ?? {}
options.textSubstitutions["mapCount"] = "" +
@ -1015,7 +1063,7 @@ export class SvgToPdf {
const state = new UIEventSource<string>("Initializing...")
this.status = state
this._status = state
this._pages = pages.map((page) => new SvgToPdfPage(page, state, options))
this._pages = pages.map((page) => new SvgToPdfPage(page, state, options, this._status))
}
/**
@ -1029,15 +1077,13 @@ export class SvgToPdf {
const mode = width > height ? "landscape" : "portrait"
await this.Prepare(language)
console.log("Global prepare done")
this._status.setData("Maps are rendered, building pdf")
console.log("Pages are prepared")
const doc = new jsPDF(mode, undefined, [width, height])
doc.advancedAPI((advancedApi) => {
for (let i = 0; i < this._pages.length; i++) {
console.log("Rendering page", i)
this._status.set("Rendering page "+ i)
if (i > 0) {
const page = this._pages[i]._svgRoot
const width = SvgToPdfInternals.attrNumber(page, "width")
@ -1061,7 +1107,6 @@ export class SvgToPdf {
this._pages[i].drawPage(advancedApi, i, language)
}
})
console.log("Exporting...")
await doc.save(this._title + "." + language + ".pdf")
}