Fix: decent PDF-export

This commit is contained in:
Pieter Vander Vennet 2023-06-04 22:52:13 +02:00
parent 905f796baa
commit de20b00b8f
15 changed files with 619 additions and 1396 deletions

View file

@ -84,7 +84,7 @@ export default class TileLocalStorage<T> {
const maxAge = this._maxAgeSeconds const maxAge = this._maxAgeSeconds
const timeDiff = Date.now() - date const timeDiff = Date.now() - date
if (timeDiff >= maxAge) { if (timeDiff >= maxAge) {
console.log("Dropping cache for", this._layername, tileIndex, "out of date") console.debug("Dropping cache for", this._layername, tileIndex, "out of date")
await IdbLocalStorage.SetDirectly(this._layername + "_" + tileIndex, undefined) await IdbLocalStorage.SetDirectly(this._layername + "_" + tileIndex, undefined)
return undefined return undefined

View file

@ -65,14 +65,14 @@ export default class GeoJsonSource implements FeatureSource {
return return
} }
this.LoadJSONFrom(url, eventsource, layer) this.LoadJSONFrom(url, eventsource, layer)
.then((fs) => console.log("Loaded",fs.length, "features from", url)) .then((fs) => console.debug("Loaded",fs.length, "features from", url))
.catch((err) => console.error("Could not load ", url, "due to", err)) .catch((err) => console.warn("Could not load ", url, "due to", err))
return true // data is loaded, we can safely unregister return true // data is loaded, we can safely unregister
}) })
} else { } else {
this.LoadJSONFrom(url, eventsource, layer) this.LoadJSONFrom(url, eventsource, layer)
.then((fs) => console.log("Loaded",fs.length, "features from", url)) .then((fs) => console.debug("Loaded",fs.length, "features from", url))
.catch((err) => console.error("Could not load ", url, "due to", err)) .catch((err) => console.warn("Could not load ", url, "due to", err))
} }
this.features = eventsource this.features = eventsource
} }

View file

@ -41,7 +41,7 @@ export class AvailableRasterLayers {
type: "Feature", type: "Feature",
properties: { properties: {
name: "MapTiler", name: "MapTiler",
url: null, url: "https://api.maptiler.com/maps/15cc8f61-0353-4be6-b8da-13daea5f7432/style.json?key=GvoVAJgu46I5rZapJuAy",
category: "osmbasedmap", category: "osmbasedmap",
id: "maptiler", id: "maptiler",
attribution: { attribution: {

View file

@ -159,7 +159,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
*/ */
if(this.layout.layers.some(l => l._needsFullNodeDatabase)){ if (this.layout.layers.some(l => l._needsFullNodeDatabase)) {
this.fullNodeDatabase = new FullNodeDatabaseSource() this.fullNodeDatabase = new FullNodeDatabaseSource()
} }
@ -181,9 +181,10 @@ export default class ThemeViewState implements SpecialVisualizationState {
} }
currentViewIndex++ currentViewIndex++
return <Feature[]>[bbox.asGeoJson({ return <Feature[]>[bbox.asGeoJson({
zoom: this.mapProperties.zoom.data, zoom: this.mapProperties.zoom.data,
...this.mapProperties.location.data, ...this.mapProperties.location.data,
id: "current_view" } id: "current_view"
}
)]; )];
} }
) )
@ -240,39 +241,6 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.featureProperties, this.featureProperties,
fs.layer.layerDef.maxAgeOfCache fs.layer.layerDef.maxAgeOfCache
) )
const doShowLayer = this.mapProperties.zoom.map(
(z) =>
(fs.layer.isDisplayed?.data ?? true) && z >= (fs.layer.layerDef?.minzoom ?? 0),
[fs.layer.isDisplayed]
)
if (
!doShowLayer.data &&
(this.featureSwitches.featureSwitchFilter.data === false || !fs.layer.layerDef.name)
) {
/* This layer is hidden and there is no way to enable it (filterview is disabled or this layer doesn't show up in the filter view as the name is not defined)
*
* This means that we don't have to filter it, nor do we have to display it
* */
return
}
const filtered = new FilteringFeatureSource(
fs.layer,
fs,
(id) => this.featureProperties.getStore(id),
this.layerState.globalFilters
)
new ShowDataLayer(this.map, {
layer: fs.layer.layerDef,
features: filtered,
doShowLayer,
selectedElement: this.selectedElement,
selectedLayer: this.selectedLayer,
fetchStore: (id) => this.featureProperties.getStore(id),
})
}) })
this.floors = this.featuresInView.features.stabilized(500).map((features) => { this.floors = this.featuresInView.features.stabilized(500).map((features) => {
@ -317,6 +285,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.changes this.changes
) )
this.showNormalDataOn(this.map)
this.initActors() this.initActors()
this.addLastClick(lastClick) this.addLastClick(lastClick)
this.drawSpecialLayers() this.drawSpecialLayers()
@ -327,6 +296,42 @@ export default class ThemeViewState implements SpecialVisualizationState {
} }
} }
public showNormalDataOn(map: Store<MlMap>) {
this.perLayer.forEach((fs) => {
const doShowLayer = this.mapProperties.zoom.map(
(z) =>
(fs.layer.isDisplayed?.data ?? true) && z >= (fs.layer.layerDef?.minzoom ?? 0),
[fs.layer.isDisplayed]
)
if (
!doShowLayer.data &&
(this.featureSwitches.featureSwitchFilter.data === false || !fs.layer.layerDef.name)
) {
/* This layer is hidden and there is no way to enable it (filterview is disabled or this layer doesn't show up in the filter view as the name is not defined)
*
* This means that we don't have to filter it, nor do we have to display it
* */
return
}
const filtered = new FilteringFeatureSource(
fs.layer,
fs,
(id) => this.featureProperties.getStore(id),
this.layerState.globalFilters
)
new ShowDataLayer(map, {
layer: fs.layer.layerDef,
features: filtered,
doShowLayer,
selectedElement: this.selectedElement,
selectedLayer: this.selectedLayer,
fetchStore: (id) => this.featureProperties.getStore(id),
})
})
}
/** /**
* Various small methods that need to be called * Various small methods that need to be called
*/ */

View file

@ -1,378 +0,0 @@
import Combine from "../Base/Combine"
import {FlowPanelFactory, FlowStep} from "../ImportFlow/FlowStep"
import {ImmutableStore, Store, UIEventSource} from "../../Logic/UIEventSource"
import {InputElement} from "../Input/InputElement"
import {SvgToPdf, SvgToPdfOptions} from "../../Utils/svgToPdf"
import {FixedInputElement} from "../Input/FixedInputElement"
import {FixedUiElement} from "../Base/FixedUiElement"
import FileSelectorButton from "../Input/FileSelectorButton"
import InputElementMap from "../Input/InputElementMap"
import {RadioButton} from "../Input/RadioButton"
import {Utils} from "../../Utils"
import {VariableUiElement} from "../Base/VariableUIElement"
import Loading from "../Base/Loading"
import BaseUIElement from "../BaseUIElement"
import Img from "../Base/Img"
import Title from "../Base/Title"
import {CheckBox} from "../Input/Checkboxes"
import Toggle from "../Input/Toggle"
import List from "../Base/List"
import LeftIndex from "../Base/LeftIndex"
import Constants from "../../Models/Constants"
import Toggleable from "../Base/Toggleable"
import Lazy from "../Base/Lazy"
import LinkToWeblate from "../Base/LinkToWeblate"
import Link from "../Base/Link"
import {AllLanguagesSelector} from "../Popup/AllLanguagesSelector"
class SelectTemplate extends Combine implements FlowStep<{ title: string; pages: string[] }> {
readonly IsValid: Store<boolean>
readonly Value: Store<{ title: string; pages: string[] }>
constructor() {
const elements: InputElement<{ templateName: string; pages: string[] }>[] = []
for (const templateName in SvgToPdf.templates) {
const template = SvgToPdf.templates[templateName]
elements.push(
new FixedInputElement(
new Combine([
new FixedUiElement(templateName).SetClass("font-bold pr-2"),
template.description,
]),
new UIEventSource({ templateName, pages: template.pages })
)
)
}
const file = new FileSelectorButton(
new FixedUiElement("Select an svg image which acts as template"),
{
acceptType: "image/svg+xml",
allowMultiple: true,
}
)
const fileMapped = new InputElementMap<
FileList,
{ templateName: string; pages: string[]; fromFile: true }
>(
file,
(x0, x1) => x0 === x1,
(filelist) => {
if (filelist === undefined) {
return undefined
}
const pages = []
let templateName: string = undefined
for (const file of Array.from(filelist)) {
if (templateName == undefined) {
templateName = file.name.substring(file.name.lastIndexOf("/") + 1)
templateName = templateName.substring(0, templateName.lastIndexOf("."))
}
pages.push(file.text())
}
return {
templateName,
pages,
fromFile: true,
}
},
(_) => undefined
)
elements.push(fileMapped)
const radio = new RadioButton(elements, { selectFirstAsDefault: true })
const loaded: Store<{ success: { title: string; pages: string[] } } | { error: any }> =
radio.GetValue().bind((template) => {
if (template === undefined) {
return undefined
}
if (template["fromFile"]) {
return UIEventSource.FromPromiseWithErr(
Promise.all(template.pages).then((pages) => ({
title: template.templateName,
pages,
}))
)
}
const urls = template.pages.map((p) => SelectTemplate.ToUrl(p))
const dloadAll: Promise<{ title: string; pages: string[] }> = Promise.all(
urls.map((url) => Utils.download(url))
).then((pages) => ({
pages,
title: template.templateName,
}))
return UIEventSource.FromPromiseWithErr(dloadAll)
})
const preview = new VariableUiElement(
loaded.map((pages) => {
if (pages === undefined) {
return new Loading()
}
if (pages["error"] !== undefined) {
return new FixedUiElement("Loading preview failed: " + pages["err"]).SetClass(
"alert"
)
}
const svgs = pages["success"].pages
if (svgs.length === 0) {
return new FixedUiElement("No pages loaded...").SetClass("alert")
}
const els: BaseUIElement[] = []
for (const pageSrc of svgs) {
const el = new Img(pageSrc, true).SetClass("w-96 m-2 border-black border-2")
els.push(el)
}
return new Combine(els).SetClass("flex border border-subtle rounded-xl")
})
)
super([new Title("Select template"), radio, new Title("Preview"), preview])
this.Value = loaded.map((l) => (l === undefined ? undefined : l["success"]))
this.IsValid = this.Value.map((v) => v !== undefined)
}
public static ToUrl(spec: string) {
if (spec.startsWith("http")) {
return spec
}
let path = window.location.pathname
path = path.substring(0, path.lastIndexOf("/"))
return window.location.protocol + "//" + window.location.host + path + "/" + spec
}
}
class SelectPdfOptions
extends Combine
implements FlowStep<{ title: string; pages: string[]; options: SvgToPdfOptions }>
{
readonly IsValid: Store<boolean>
readonly Value: Store<{ title: string; pages: string[]; options: SvgToPdfOptions }>
constructor(title: string, pages: string[], getFreeDiv: () => string) {
const dummy = new CheckBox("Don't add data to the map (to quickly preview the PDF)", false)
const overrideMapLocation = new CheckBox(
"Override map location: use a selected location instead of the location set in the template",
false
)
const locationInput = Minimap.createMiniMap().SetClass("block w-full")
const searchField = undefined // new SearchAndGo({ leafletMap: locationInput.leafletMap })
const selectLocation = new Combine([
new Toggle(
new Combine([new Title("Select override location"), searchField]).SetClass("flex"),
undefined,
overrideMapLocation.GetValue()
),
new Toggle(
locationInput.SetStyle("height: 20rem"),
undefined,
overrideMapLocation.GetValue()
).SetStyle("height: 20rem"),
])
.SetClass("block")
.SetStyle("height: 25rem")
super([new Title("Select options"), dummy, overrideMapLocation, selectLocation])
this.Value = dummy.GetValue().map(
(disableMaps) => {
return {
pages,
title,
options: <SvgToPdfOptions>{
disableMaps,
getFreeDiv,
overrideLocation: overrideMapLocation.GetValue().data
? locationInput.location.data
: undefined,
},
}
},
[overrideMapLocation.GetValue(), locationInput.location]
)
this.IsValid = new ImmutableStore(true)
}
}
class PreparePdf extends Combine implements FlowStep<{ svgToPdf: SvgToPdf; languages: string[] }> {
readonly IsValid: Store<boolean>
readonly Value: Store<{ svgToPdf: SvgToPdf; languages: string[] }>
constructor(title: string, pages: string[], options: SvgToPdfOptions) {
const svgToPdf = new SvgToPdf(title, pages, options)
const languageSelector = new AllLanguagesSelector()
const isPrepared = UIEventSource.FromPromiseWithErr(svgToPdf.Prepare())
super([
new Title("Select languages..."),
languageSelector,
new Toggle(
new Loading("Preparing maps..."),
undefined,
isPrepared.map((p) => p === undefined)
),
])
this.Value = isPrepared.map(
(isPrepped) => {
if (isPrepped === undefined) {
return undefined
}
if (isPrepped["success"] !== undefined) {
const svgToPdf = isPrepped["success"]
const langs = languageSelector.GetValue().data
console.log("Languages are", langs)
if (langs.length === 0) {
return undefined
}
return { svgToPdf, languages: langs }
}
return undefined
},
[languageSelector.GetValue()]
)
this.IsValid = this.Value.map((v) => v !== undefined)
}
}
class InspectStrings
extends Toggle
implements FlowStep<{ svgToPdf: SvgToPdf; languages: string[] }>
{
readonly IsValid: Store<boolean>
readonly Value: Store<{ svgToPdf: SvgToPdf; languages: string[] }>
constructor(svgToPdf: SvgToPdf, languages: string[]) {
const didLoadLanguages = UIEventSource.FromPromiseWithErr(
svgToPdf.PrepareLanguages(languages)
).map((l) => l !== undefined && l["success"] !== undefined)
super(
new Combine([
new Title("Inspect translation strings"),
...languages.map(
(l) => new Lazy(() => InspectStrings.createOverviewPanel(svgToPdf, l))
),
]),
new Loading(),
didLoadLanguages
)
this.Value = new ImmutableStore({ svgToPdf, languages })
this.IsValid = didLoadLanguages
}
private static createOverviewPanel(svgToPdf: SvgToPdf, language: string): BaseUIElement {
const elements: BaseUIElement[] = []
let foundTranslations = 0
const allKeys = Array.from(svgToPdf.translationKeys())
for (const translationKey of allKeys) {
let spec = translationKey
if (translationKey.startsWith("layer.")) {
spec = "layers:" + translationKey.substring(6)
} else {
spec = "core:" + translationKey
}
const translated = svgToPdf.getTranslation("$" + translationKey, language, true)
if (translated) {
foundTranslations++
}
const linkToWeblate = new Link(
spec,
LinkToWeblate.hrefToWeblate(language, spec),
true
).SetClass("font-bold link-underline")
elements.push(
new Combine([
linkToWeblate,
"&nbsp;",
translated ?? new FixedUiElement("No translation found!").SetClass("alert"),
])
)
}
return new Toggleable(
new Title("Translations for " + language),
new Combine([
`${foundTranslations}/${allKeys.length} of translations are found (${Math.floor(
(100 * foundTranslations) / allKeys.length
)}%)`,
"The following keys are used:",
new List(elements),
]),
{ closeOnClick: false, height: "15rem" }
)
}
}
class SavePdf extends Combine {
constructor(svgToPdf: SvgToPdf, languages: string[]) {
super([
new Title("Generating your pdfs..."),
new List(
languages.map(
(lng) =>
new Toggle(
lng + " is done!",
new Loading("Creating pdf for " + lng),
UIEventSource.FromPromiseWithErr(
svgToPdf.ConvertSvg(lng).then(() => true)
).map((x) => x !== undefined && x["success"] === true)
)
)
),
])
}
}
export class PdfExportGui extends LeftIndex {
constructor(freeDivId: string) {
let i = 0
const createDiv = (): string => {
const div = document.createElement("div")
div.id = "freediv-" + i++
document.getElementById(freeDivId).append(div)
return div.id
}
Constants.defaultOverpassUrls.splice(0, 1)
const { flow, furthestStep, titles } = FlowPanelFactory.start(
new Title("Select template"),
new SelectTemplate()
)
.then(
new Title("Select options"),
({ title, pages }) => new SelectPdfOptions(title, pages, createDiv)
)
.then(
"Generate maps...",
({ title, pages, options }) => new PreparePdf(title, pages, options)
)
.then(
"Inspect translations",
({ svgToPdf, languages }) => new InspectStrings(svgToPdf, languages)
)
.finish("Generating...", ({ svgToPdf, languages }) => new SavePdf(svgToPdf, languages))
const toc = new List(
titles.map(
(title, i) =>
new VariableUiElement(
furthestStep.map((currentStep) => {
if (i > currentStep) {
return new Combine([title]).SetClass("subtle")
}
if (i == currentStep) {
return new Combine([title]).SetClass("font-bold")
}
if (i < currentStep) {
return title
}
})
)
),
true
)
const leftContents: BaseUIElement[] = [toc].map((el) => el?.SetClass("pl-4"))
super(leftContents, flow)
}
}

View file

@ -10,12 +10,13 @@
import DownloadHelper from "./DownloadHelper"; import DownloadHelper from "./DownloadHelper";
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import type {PriviligedLayerType} from "../../Models/Constants"; import type {PriviligedLayerType} from "../../Models/Constants";
import {UIEventSource} from "../../Logic/UIEventSource";
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
export let extension: string export let extension: string
export let mimetype: string export let mimetype: string
export let construct: (geojsonCleaned: FeatureCollection) => (Blob | string) | Promise<void> export let construct: (geojsonCleaned: FeatureCollection, title: string) => (Blob | string) | Promise<void>
export let mainText: Translation export let mainText: Translation
export let helperText: Translation export let helperText: Translation
export let metaIsIncluded: boolean export let metaIsIncluded: boolean
@ -26,51 +27,63 @@
let isExporting = false let isExporting = false
let isError = false let isError = false
let status: UIEventSource<string> = new UIEventSource<string>(undefined)
async function clicked() { async function clicked() {
isExporting = true isExporting = true
const gpsLayer = state.layerState.filteredLayers.get( const gpsLayer = state.layerState.filteredLayers.get(
<PriviligedLayerType>"gps_location" <PriviligedLayerType>"gps_location"
) )
state.lastClickObject.features.setData([]) state.lastClickObject.features.setData([])
const gpsIsDisplayed = gpsLayer.isDisplayed.data const gpsIsDisplayed = gpsLayer.isDisplayed.data
try { try {
gpsLayer.isDisplayed.setData(false) gpsLayer.isDisplayed.setData(false)
const geojson: FeatureCollection = downloadHelper.getCleanGeoJson(metaIsIncluded) const geojson: FeatureCollection = downloadHelper.getCleanGeoJson(metaIsIncluded)
const name = state.layout.id const name = state.layout.id
const promise = construct(geojson) const title = `MapComplete_${name}_export_${new Date().toISOString().substr(0, 19)}.${extension}`
const promise = construct(geojson, title)
let data: Blob | string let data: Blob | string
if (typeof promise === "string") { if (typeof promise === "string") {
data = promise data = promise
} else if (typeof promise["then"] === "function") { } else if (typeof promise["then"] === "function") {
data = await <Promise<Blob | string>> promise data = await <Promise<Blob | string>>promise
} else { } else {
data = <Blob>promise data = <Blob>promise
} }
if (!data) {
return
}
console.log("Got data", data) console.log("Got data", data)
Utils.offerContentsAsDownloadableFile( Utils.offerContentsAsDownloadableFile(
data, data,
`MapComplete_${name}_export_${new Date().toISOString().substr(0, 19)}.${extension}`, title,
{ {
mimetype, mimetype,
} }
) )
} catch (e) { } catch (e) {
isError = true isError = true
console.error(e)
} finally {
isExporting = false
gpsLayer.isDisplayed.setData(gpsIsDisplayed)
} }
gpsLayer.isDisplayed.setData(gpsIsDisplayed)
isExporting = false
} }
</script> </script>
{#if isError} {#if isError}
<Tr cls="alert" t={Translations.t.error}/> <Tr cls="alert" t={Translations.t.general.error}/>
{:else if isExporting} {:else if isExporting}
<Loading> <Loading>
<Tr t={t.exporting}/> {#if $status}
{$status}
{:else}
<Tr t={t.exporting}/>
{/if}
</Loading> </Loading>
{:else} {:else}
<button class="flex w-full" on:click={clicked}> <button class="flex w-full" on:click={clicked}>

View file

@ -1,14 +1,21 @@
<script lang="ts"> <script lang="ts">
import type {SpecialVisualizationState} from "../SpecialVisualization";
import Loading from "../Base/Loading.svelte"; import Loading from "../Base/Loading.svelte";
import Translations from "../i18n/Translations"; import Translations from "../i18n/Translations";
import Tr from "../Base/Tr.svelte"; import Tr from "../Base/Tr.svelte";
import DownloadHelper from "./DownloadHelper"; import DownloadHelper from "./DownloadHelper";
import DownloadButton from "./DownloadButton.svelte"; import DownloadButton from "./DownloadButton.svelte";
import {GeoOperations} from "../../Logic/GeoOperations"; import {GeoOperations} from "../../Logic/GeoOperations";
import {SvgToPdf} from "../../Utils/svgToPdf";
import Locale from "../i18n/Locale";
import ThemeViewState from "../../Models/ThemeViewState";
import {Utils} from "../../Utils";
import Constants from "../../Models/Constants";
import {Translation} from "../i18n/Translation";
import {AvailableRasterLayers} from "../../Models/RasterLayers";
import {UIEventSource} from "../../Logic/UIEventSource";
export let state: SpecialVisualizationState export let state: ThemeViewState
let isLoading = state.dataIsLoading let isLoading = state.dataIsLoading
const t = Translations.t.general.download const t = Translations.t.general.download
@ -30,6 +37,30 @@
}) })
} }
async function constructPdf(_, title: string, status: UIEventSource<string>) {
const templateUrls = SvgToPdf.templates["current_view_a3"].pages
const templates: string[] = await Promise.all(templateUrls.map(url => Utils.download(url)))
console.log("Templates are", templates)
const bg = state.mapProperties.rasterLayer.data ?? AvailableRasterLayers.maplibre
const creator = new SvgToPdf(title, templates, {
state,
freeComponentId: "belowmap",
textSubstitutions: <Record<string, string>> {
"layout.title": state.layout.title,
title: state.layout.title,
layoutImg: state.layout.icon,
version: Constants.vNumber,
date: new Date().toISOString().substring(0,16),
background: new Translation(bg.properties.name).txt
}
})
const unsub = creator.status.addCallbackAndRunD(s => status?.setData(s))
await creator.ExportPdf(Locale.language.data)
unsub()
return undefined
}
</script> </script>
@ -85,7 +116,7 @@
extension="pdf" extension="pdf"
mainText={t.downloadAsPdf} mainText={t.downloadAsPdf}
helperText={t.downloadAsPdfHelper} helperText={t.downloadAsPdfHelper}
construct={_ => state.mapProperties.exportAsPng(4)} construct={constructPdf}
/> />

View file

@ -199,7 +199,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
return url return url
} }
private static setDpi(drawOn: HTMLCanvasElement, ctx: CanvasRenderingContext2D, dpiFactor: number) { public static setDpi(drawOn: HTMLCanvasElement, ctx: CanvasRenderingContext2D, dpiFactor: number) {
drawOn.style.width = drawOn.style.width || drawOn.width + "px" drawOn.style.width = drawOn.style.width || drawOn.width + "px"
drawOn.style.height = drawOn.style.height || drawOn.height + "px" drawOn.style.height = drawOn.style.height || drawOn.height + "px"
@ -223,23 +223,16 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
console.log("Canvas size:", drawOn.width, drawOn.height) console.log("Canvas size:", drawOn.width, drawOn.height)
const ctx = drawOn.getContext("2d") const ctx = drawOn.getContext("2d")
// Set up CSS size. // Set up CSS size.
MapLibreAdaptor.setDpi(drawOn, ctx, dpiFactor) MapLibreAdaptor.setDpi(drawOn, ctx, dpiFactor / map.getPixelRatio())
await this.exportBackgroundOnCanvas(drawOn, ctx, dpiFactor) await this.exportBackgroundOnCanvas(ctx)
drawOn.toBlob(blob => {
Utils.offerContentsAsDownloadableFile(blob, "bg.png")
})
console.log("Getting markers") console.log("Getting markers")
// MapLibreAdaptor.setDpi(drawOn, ctx, 1) // MapLibreAdaptor.setDpi(drawOn, ctx, 1)
const markers = await this.drawMarkers(dpiFactor) const markers = await this.drawMarkers(dpiFactor)
console.log("Drawing markers (" + markers.width + "*" + markers.height + ") onto drawOn (" + drawOn.width + "*" + drawOn.height + ")") console.log("Drawing markers (" + markers.width + "*" + markers.height + ") onto drawOn (" + drawOn.width + "*" + drawOn.height + ")")
ctx.scale(1/dpiFactor,1/dpiFactor )
ctx.drawImage(markers, 0, 0, drawOn.width, drawOn.height) ctx.drawImage(markers, 0, 0, drawOn.width, drawOn.height)
ctx.scale(dpiFactor, dpiFactor) ctx.scale(dpiFactor, dpiFactor)
markers.toBlob(blob => {
Utils.offerContentsAsDownloadableFile(blob, "markers.json")
})
this._maplibreMap.data?.resize() this._maplibreMap.data?.resize()
return await new Promise<Blob>(resolve => drawOn.toBlob(blob => resolve(blob))) return await new Promise<Blob>(resolve => drawOn.toBlob(blob => resolve(blob)))
} }
@ -248,7 +241,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
* Exports the background map and lines to PNG. * Exports the background map and lines to PNG.
* Markers are _not_ rendered * Markers are _not_ rendered
*/ */
private async exportBackgroundOnCanvas(drawOn: HTMLCanvasElement, ctx: CanvasRenderingContext2D, dpiFactor: number = 1): Promise<void> { private async exportBackgroundOnCanvas(ctx: CanvasRenderingContext2D): Promise<void> {
const map = this._maplibreMap.data const map = this._maplibreMap.data
// We draw the maplibre-map onto the canvas. This does not export markers // We draw the maplibre-map onto the canvas. This does not export markers
// Inspiration by https://github.com/mapbox/mapbox-gl-js/issues/2766 // Inspiration by https://github.com/mapbox/mapbox-gl-js/issues/2766

View file

@ -8,6 +8,7 @@
import { Map } from "@onsvisual/svelte-maps"; import { Map } from "@onsvisual/svelte-maps";
import type { Map as MaplibreMap } from "maplibre-gl"; import type { Map as MaplibreMap } from "maplibre-gl";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import {AvailableRasterLayers} from "../../Models/RasterLayers";
/** /**
@ -24,7 +25,7 @@
$map.resize(); $map.resize();
}); });
}); });
const styleUrl = "https://api.maptiler.com/maps/15cc8f61-0353-4be6-b8da-13daea5f7432/style.json?key=GvoVAJgu46I5rZapJuAy"; const styleUrl = AvailableRasterLayers.maplibre.properties.url;
</script> </script>
<main> <main>
<Map bind:center={center} <Map bind:center={center}

View file

@ -1,8 +1,9 @@
import ThemeViewState from "../Models/ThemeViewState" import ThemeViewState from "../Models/ThemeViewState"
import SvelteUIElement from "../UI/Base/SvelteUIElement" import {Utils} from "../Utils"
import MaplibreMap from "../UI/Map/MaplibreMap.svelte" import {UIEventSource} from "../Logic/UIEventSource"
import { Utils } from "../Utils" import {Map as MlMap} from "maplibre-gl"
import { UIEventSource } from "../Logic/UIEventSource" import {MapLibreAdaptor} from "../UI/Map/MapLibreAdaptor";
import {AvailableRasterLayers} from "../Models/RasterLayers";
export interface PngMapCreatorOptions { export interface PngMapCreatorOptions {
readonly width: number readonly width: number
@ -23,33 +24,56 @@ export class PngMapCreator {
* Creates a base64-encoded PNG image * Creates a base64-encoded PNG image
* @constructor * @constructor
*/ */
public async CreatePng(status: UIEventSource<string>): Promise<Blob> { public async CreatePng(freeComponentId: string, status?: UIEventSource<string>): Promise<Blob> {
const div = document.createElement("div") const div = document.createElement("div")
div.id = "mapdiv-" + PngMapCreator.id div.id = "mapdiv-" + PngMapCreator.id
div.style.width = this._options.width + "mm"
div.style.height = this._options.height + "mm"
PngMapCreator.id++ PngMapCreator.id++
const layout = this._state.layout const layout = this._state.layout
function setState(msg: string) { function setState(msg: string) {
status.setData(layout.id + ": " + msg) status?.setData(layout.id + ": " + msg)
} }
setState("Initializing map") setState("Initializing map")
const map = this._state.map
new SvelteUIElement(MaplibreMap, { map }) const settings = this._state.mapProperties
.SetStyle( const l = settings.location.data
"width: " + this._options.width + "mm; height: " + this._options.height + "mm; border: 2px solid red;"
) document.getElementById(freeComponentId).appendChild(div)
.AttachTo("extradiv") const mapElem = new MlMap({
map.data.resize() container: div.id,
style: AvailableRasterLayers.maplibre.properties.url,
center: [l.lon, l.lat],
zoom: settings.zoom.data,
pixelRatio: 6
});
const map = new UIEventSource<MlMap>(mapElem)
const mla = new MapLibreAdaptor(map)
mla.zoom.setData(settings.zoom.data)
mla.location.setData(settings.location.data)
mla.rasterLayer.setData(settings.rasterLayer.data)
this._state?.showNormalDataOn(map)
console.log("Creating a map with size", this._options.width, this._options.height)
setState("Waiting for the data") setState("Waiting for the data")
await this._state.dataIsLoading.AsPromise((loading) => !loading) await this._state.dataIsLoading.AsPromise((loading) => !loading)
setState("Waiting for styles to be fully loaded") setState("Waiting for styles to be fully loaded")
while (!map?.data?.isStyleLoaded()) { while (!map?.data?.isStyleLoaded()) {
console.log("Waiting for the style to be loaded...")
await Utils.waitFor(250) await Utils.waitFor(250)
} }
// Some extra buffer... // Some extra buffer...
await Utils.waitFor(1000) await Utils.waitFor(1000)
setState("Exporting png") setState("Exporting png")
console.log("Loading for", this._state.layout.id, "is done") console.log("Loading for", this._state.layout.id, "is done")
console.log("Map export: starting actual export, target size is", this._options.width,"mm * ",this._options.height+"mm") const png = await mla.exportAsPng(6)
return this._state.mapProperties.exportAsPng(4) div.parentElement.removeChild(div)
Utils.offerContentsAsDownloadableFile(png, "test.png")
return png
} }
} }

View file

@ -12,7 +12,6 @@ import Constants from "../Models/Constants"
import Hash from "../Logic/Web/Hash" import Hash from "../Logic/Web/Hash"
import ThemeViewState from "../Models/ThemeViewState" import ThemeViewState from "../Models/ThemeViewState"
import {Store, UIEventSource} from "../Logic/UIEventSource" import {Store, UIEventSource} from "../Logic/UIEventSource"
import {FixedUiElement} from "../UI/Base/FixedUiElement"
class SvgToPdfInternals { class SvgToPdfInternals {
private static readonly dummyDoc: jsPDF = new jsPDF() private static readonly dummyDoc: jsPDF = new jsPDF()
@ -260,15 +259,29 @@ class SvgToPdfInternals {
const ry = SvgToPdfInternals.attrNumber(element, "ry", false) ?? 0 const ry = SvgToPdfInternals.attrNumber(element, "ry", false) ?? 0
const rx = SvgToPdfInternals.attrNumber(element, "rx", false) ?? 0 const rx = SvgToPdfInternals.attrNumber(element, "rx", false) ?? 0
const css = SvgToPdfInternals.css(element) const css = SvgToPdfInternals.css(element)
this.doc.saveGraphicsState()
if (css["fill-opacity"] !== "0" && css["fill"] !== "none") { if (css["fill-opacity"] !== "0" && css["fill"] !== "none") {
this.doc.setFillColor(css["fill"] ?? "black") let color = css["fill"] ?? "black"
let opacity = 1
if (css["fill-opacity"]) {
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") this.doc.roundedRect(x, y, width, height, rx, ry, "F")
} }
if (css["stroke"] && css["stroke"] !== "none") { if (css["stroke"] && css["stroke"] !== "none") {
this.doc.setLineWidth(Number(css["stroke-width"] ?? 1)) this.doc.setLineWidth(Number(css["stroke-width"] ?? 1))
this.doc.setDrawColor(css["stroke"] ?? "black") this.doc.setDrawColor(css["stroke"] ?? "black")
if (css["opacity"]) {
const opacity = Number(css["opacity"])
this.doc.setGState(this.doc.GState({"stroke-opacity": opacity}))
}
this.doc.roundedRect(x, y, width, height, rx, ry, "S") this.doc.roundedRect(x, y, width, height, rx, ry, "S")
} }
this.doc.restoreGraphicsState()
return return
} }
@ -290,13 +303,27 @@ class SvgToPdfInternals {
} }
private drawTspan(tspan: Element) { private drawTspan(tspan: Element) {
if (tspan.textContent == "") { const txt = tspan.textContent
if (txt == "") {
return return
} }
const x = SvgToPdfInternals.attrNumber(tspan, "x") const x = SvgToPdfInternals.attrNumber(tspan, "x")
const y = SvgToPdfInternals.attrNumber(tspan, "y") const y = SvgToPdfInternals.attrNumber(tspan, "y")
const css = SvgToPdfInternals.css(tspan) const css = SvgToPdfInternals.css(tspan)
const imageMatch = txt.match(/\$img\(([^)])+\)/)
if (imageMatch) {
const [key, width, height] = imageMatch[1].split(",").map(s => s.trim())
const url = key.startsWith("http") ? key : this.extractTranslation("${" + key + "}")
const img = this._images[url]
console.log("Drawing an injected image", {key, url, img: img.src})
this.doc.addImage(
img.src
, x, y, Number(width), Number(height)
)
return
}
let maxWidth: number = undefined let maxWidth: number = undefined
if (css["shape-inside"]) { if (css["shape-inside"]) {
const matched = css["shape-inside"].match(/url\(#([a-zA-Z0-9-]+)\)/) const matched = css["shape-inside"].match(/url\(#([a-zA-Z0-9-]+)\)/)
@ -322,7 +349,6 @@ class SvgToPdfInternals {
this.doc.setTextColor("black") this.doc.setTextColor("black")
} }
let fontsize = parseFloat(css["font-size"]) let fontsize = parseFloat(css["font-size"])
this.doc.setFontSize(fontsize * 2.5) this.doc.setFontSize(fontsize * 2.5)
let textTemplate = tspan.textContent.split(" ") let textTemplate = tspan.textContent.split(" ")
@ -339,7 +365,13 @@ class SvgToPdfInternals {
addSpace = false addSpace = false
continue continue
} }
if (text.startsWith(`$\{`)) {
if (addSpace) {
result += " "
}
result += this.extractTranslation(text)
continue
}
if (!text.startsWith("$")) { if (!text.startsWith("$")) {
if (addSpace) { if (addSpace) {
result += " " result += " "
@ -496,11 +528,16 @@ class SvgToPdfInternals {
} }
export interface SvgToPdfOptions { export interface SvgToPdfOptions {
freeComponentId: string,
disableMaps?: false | true disableMaps?: false | true
textSubstitutions?: Record<string, string> textSubstitutions?: Record<string, string>
beforePage?: (i: number) => void beforePage?: (i: number) => void
overrideLocation?: { lat: number; lon: number }, overrideLocation?: { lat: number; lon: number },
disableDataLoading?: boolean | false disableDataLoading?: boolean | false,
/**
* Override all the maps to generate with this map
*/
state?: ThemeViewState
} }
class SvgToPdfPage { class SvgToPdfPage {
@ -569,7 +606,6 @@ class SvgToPdfPage {
if (element.tagName === "rect") { if (element.tagName === "rect") {
this.rects[element.id] = <SVGRectElement>element this.rects[element.id] = <SVGRectElement>element
} }
if (element.tagName === "image") { if (element.tagName === "image") {
await this.loadImage(element) await this.loadImage(element)
} }
@ -577,6 +613,14 @@ class SvgToPdfPage {
if (element.tagName === "tspan" && element.childElementCount == 0) { if (element.tagName === "tspan" && element.childElementCount == 0) {
const specialValues = element.textContent.split(" ").filter((t) => t.startsWith("$")) const specialValues = element.textContent.split(" ").filter((t) => t.startsWith("$"))
for (let specialValue of specialValues) { for (let specialValue of specialValues) {
const imageMatch = element.textContent.match(/\$img\(([^)])+\)/)
if (imageMatch) {
const key = imageMatch[1]
const url = key.startsWith("http") ? key : this.extractTranslation("${" + key + "}", `en`, false)
await this.loadImage(url)
continue
}
const importMatch = element.textContent.match( const importMatch = element.textContent.match(
/\$import ([a-zA-Z-_0-9.? ]+) as ([a-zA-Z0-9]+)/ /\$import ([a-zA-Z-_0-9.? ]+) as ([a-zA-Z0-9]+)/
) )
@ -596,6 +640,7 @@ class SvgToPdfPage {
this.options.textSubstitutions this.options.textSubstitutions
) )
} }
if (element.textContent.startsWith("$map(")) { if (element.textContent.startsWith("$map(")) {
mapTextSpecs.push(<any>element) mapTextSpecs.push(<any>element)
} }
@ -642,7 +687,12 @@ class SvgToPdfPage {
} }
for (const mapSpec of mapSpecs) { for (const mapSpec of mapSpecs) {
await this.prepareMap(mapSpec,! this.options?.disableDataLoading) try{
await this.prepareMap(mapSpec, !this.options?.disableDataLoading)
}catch(e){
console.error("Couldn't prepare a map:", e)
}
} }
} }
@ -671,6 +721,10 @@ class SvgToPdfPage {
Constants.vNumber Constants.vNumber
) )
} }
if (text.startsWith("${") && text.endsWith("}")) {
const key = text.substring(2, text.length - 1)
return this.options.textSubstitutions[key]
}
const pathPart = text.match(/\$(([_a-zA-Z0-9? ]+\.)+[_a-zA-Z0-9? ]+)(.*)/) const pathPart = text.match(/\$(([_a-zA-Z0-9? ]+\.)+[_a-zA-Z0-9? ]+)(.*)/)
if (pathPart === null) { if (pathPart === null) {
return text return text
@ -715,8 +769,8 @@ class SvgToPdfPage {
} }
} }
private loadImage(element: Element): Promise<void> { private loadImage(element: Element | string): Promise<void> {
const xlink = element.getAttribute("xlink:href") const xlink = typeof element === "string" ? element : element.getAttribute("xlink:href")
let img = document.createElement("img") let img = document.createElement("img")
if (xlink.startsWith("data:image/svg+xml;")) { if (xlink.startsWith("data:image/svg+xml;")) {
@ -752,11 +806,11 @@ class SvgToPdfPage {
/** /**
* Replaces a mapSpec with the appropriate map * Replaces a mapSpec with the appropriate map
* @param mapSpec
* @private
*/ */
private async prepareMap(mapSpec: SVGTSpanElement, loadData: boolean): Promise<void> {
private async prepareMap(mapSpec: SVGTSpanElement, loadData: boolean = true): Promise<void> { if (this.options.disableMaps) {
return
}
// Upper left point of the tspan // Upper left point of the tspan
const {x, y} = SvgToPdfInternals.GetActualXY(mapSpec) const {x, y} = SvgToPdfInternals.GetActualXY(mapSpec)
@ -766,11 +820,6 @@ class SvgToPdfPage {
textElement = textElement.parentElement textElement = textElement.parentElement
} }
const spec = textElement.textContent 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 smallestRect: SVGRectElement = undefined
let smallestSurface: number = undefined let smallestSurface: number = undefined
@ -783,6 +832,7 @@ class SvgToPdfPage {
const h = SvgToPdfInternals.attrNumber(rect, "height") const h = SvgToPdfInternals.attrNumber(rect, "height")
const inBounds = rx <= x && x <= rx + w && ry <= y && y <= ry + h const inBounds = rx <= x && x <= rx + w && ry <= y && y <= ry + h
if (!inBounds) { if (!inBounds) {
console.log("Not in bounds: rectangle", id)
continue continue
} }
const surface = w * h const surface = w * h
@ -809,95 +859,111 @@ class SvgToPdfPage {
svgImage.setAttribute("width", "" + width) svgImage.setAttribute("width", "" + width)
svgImage.setAttribute("height", "" + height) svgImage.setAttribute("height", "" + height)
let layout = AllKnownLayouts.allKnownLayouts.get(params["theme"]) let png: Blob
if (layout === undefined) { if (this.options.state !== undefined) {
console.error("Could not show map with parameters", params) png = await (new PngMapCreator(this.options.state, {
throw ( width, height,
"Theme not found:" + params["theme"] + ". Use theme: to define which theme to use. " }).CreatePng(this.options.freeComponentId))
) } else {
} const match = spec.match(/\$map\(([^)]*)\)$/)
layout.widenFactor = 0 if (match === null) {
layout.overpassTimeout = 600 throw "Invalid mapspec:" + spec
layout.defaultBackgroundId = params["background"] ?? layout.defaultBackgroundId
for (const paramsKey in params) {
if (paramsKey.startsWith("layer-")) {
const layerName = paramsKey.substring("layer-".length)
const key = params[paramsKey].toLowerCase().trim()
const layer = layout.layers.find((l) => l.id === layerName)
if (layer === undefined) {
throw "No layer found for " + paramsKey
}
if (key === "force") {
layer.minzoom = 0
layer.minzoomVisible = 0
}
} }
} const params = SvgToPdfInternals.parseCss(match[1], ",")
const zoom = Number(params["zoom"] ?? params["z"] ?? 14) let layout = AllKnownLayouts.allKnownLayouts.get(params["theme"])
if (layout === undefined) {
Hash.hash.setData(undefined) console.error("Could not show map with parameters", params)
// QueryParameters.ClearAll() throw (
const state = new ThemeViewState(layout) "Theme not found:" + params["theme"] + ". Use theme: to define which theme to use. "
state.mapProperties.location.setData({
lat: this.options?.overrideLocation?.lat ?? Number(params["lat"] ?? 51.05016),
lon: this.options?.overrideLocation?.lon ?? Number(params["lon"] ?? 3.717842),
})
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) {
filteredLayer.isDisplayed.setData(
loadData && params["layer-" + filteredLayer.layerDef.id].trim().toLowerCase() !== "false"
) )
} else if (params["layers"] === "none") { }
filteredLayer.isDisplayed.setData(false) layout.widenFactor = 0
} else if (filteredLayer.layerDef.id.startsWith("note_import")) { layout.overpassTimeout = 600
filteredLayer.isDisplayed.setData(false) layout.defaultBackgroundId = params["background"] ?? layout.defaultBackgroundId
for (const paramsKey in params) {
if (paramsKey.startsWith("layer-")) {
const layerName = paramsKey.substring("layer-".length)
const key = params[paramsKey].toLowerCase().trim()
const layer = layout.layers.find((l) => l.id === layerName)
if (layer === undefined) {
throw "No layer found for " + paramsKey
}
if (key === "force") {
layer.minzoom = 0
layer.minzoomVisible = 0
}
}
}
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),
lon: this.options?.overrideLocation?.lon ?? Number(params["lon"] ?? 3.717842),
})
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) {
filteredLayer.isDisplayed.setData(
loadData && params["layer-" + filteredLayer.layerDef.id].trim().toLowerCase() !== "false"
)
} else if (params["layers"] === "none") {
filteredLayer.isDisplayed.setData(false)
} else if (filteredLayer.layerDef.id.startsWith("note_import")) {
filteredLayer.isDisplayed.setData(false)
}
}
for (const paramsKey in params) {
if (paramsKey.startsWith("layer-")) {
const layerName = paramsKey.substring("layer-".length)
const key = params[paramsKey].toLowerCase().trim()
const isDisplayed = loadData && (key === "true" || key === "force")
const layer = fl.find((l) => l.layerDef.id === layerName)
if (!loadData) {
console.log("Not loading map data as 'loadData' is falsed, this is probably a test run")
} else {
console.log(
"Setting ",
layer?.layerDef?.id,
" to visibility",
isDisplayed,
"(minzoom:",
layer?.layerDef?.minzoomVisible,
layer?.layerDef?.minzoom,
")"
)
}
layer.isDisplayed.setData(loadData && isDisplayed)
if (key === "force" && loadData) {
layer.layerDef.minzoom = 0
layer.layerDef.minzoomVisible = 0
layer.isDisplayed.addCallback((isDisplayed) => {
if (!isDisplayed) {
console.warn("Forcing layer " + paramsKey + " as true")
layer.isDisplayed.setData(true)
}
})
}
}
}
console.log("Creating a map width ", width, height, params.scalingFactor)
const pngCreator = new PngMapCreator(state, {
width: 4 * width,
height: 4 * height,
})
png = await pngCreator.CreatePng(this.options.freeComponentId, this._state)
if(!png){
throw "PngCreator did not output anything..."
} }
} }
for (const paramsKey in params) {
if (paramsKey.startsWith("layer-")) {
const layerName = paramsKey.substring("layer-".length)
const key = params[paramsKey].toLowerCase().trim()
const isDisplayed = loadData && (key === "true" || key === "force")
const layer = fl.find((l) => l.layerDef.id === layerName)
if (!loadData) {
console.log("Not loading map data as 'loadData' is falsed, this is probably a test run")
} else {
console.log(
"Setting ",
layer?.layerDef?.id,
" to visibility",
isDisplayed,
"(minzoom:",
layer?.layerDef?.minzoomVisible,
layer?.layerDef?.minzoom,
")"
)
}
layer.isDisplayed.setData(loadData && isDisplayed)
if (key === "force" && loadData) {
layer.layerDef.minzoom = 0
layer.layerDef.minzoomVisible = 0
layer.isDisplayed.addCallback((isDisplayed) => {
if (!isDisplayed) {
console.warn("Forcing layer " + paramsKey + " as true")
layer.isDisplayed.setData(true)
}
})
}
}
}
console.log("Creating a map width ", width, height, params.scalingFactor)
const pngCreator = new PngMapCreator(state, {
width: 4 * width,
height: 4 * height,
})
const png = await pngCreator.CreatePng(this._state)
svgImage.setAttribute("xlink:href", await SvgToPdfPage.blobToBase64(png)) svgImage.setAttribute("xlink:href", await SvgToPdfPage.blobToBase64(png))
smallestRect.parentElement.insertBefore(svgImage, smallestRect) smallestRect.parentElement.insertBefore(svgImage, smallestRect)
await this.prepareElement(svgImage, []) await this.prepareElement(svgImage, [])
@ -916,7 +982,7 @@ class SvgToPdfPage {
export class SvgToPdf { export class SvgToPdf {
public static readonly templates: Record< public static readonly templates: Record<
"flyer_a4" | "poster_a3" | "poster_a2" | "current_view_a4", "flyer_a4" | "poster_a3" | "poster_a2" | "current_view_a4" | "current_view_a3",
{ pages: string[]; description: string | Translation; isPublic: boolean } { pages: string[]; description: string | Translation; isPublic: boolean }
> = { > = {
flyer_a4: { flyer_a4: {
@ -938,8 +1004,13 @@ export class SvgToPdf {
isPublic: false isPublic: false
}, },
current_view_a4: { current_view_a4: {
pages:["./assets/templates/CurrentMapWithHeaderA4.svg"], pages: ["./assets/templates/CurrentMapWithHeaderA4.svg"],
description: "Export a PDF (A4, portrait) of the current view", description: "Export a PDF (A4, landscape) of the current view",
isPublic: true
},
current_view_a3: {
pages: ["./assets/templates/CurrentMapWithHeaderA3.svg"],
description: "Export a PDF (A3, portrait) of the current view",
isPublic: true isPublic: true
} }
} }
@ -965,7 +1036,10 @@ export class SvgToPdf {
this._pages = pages.map((page) => new SvgToPdfPage(page, state, options)) this._pages = pages.map((page) => new SvgToPdfPage(page, state, options))
} }
public async ConvertSvg(language: string): Promise<void> { /**
* Construct the PDF (including the maps to create), offers them to the user to downlaod.
*/
public async ExportPdf(language: string): Promise<void> {
console.log("Building svg...") console.log("Building svg...")
const firstPage = this._pages[0]._svgRoot const firstPage = this._pages[0]._svgRoot
const width = SvgToPdfInternals.attrNumber(firstPage, "width") const width = SvgToPdfInternals.attrNumber(firstPage, "width")
@ -975,13 +1049,12 @@ export class SvgToPdf {
await this.Prepare(language) await this.Prepare(language)
console.log("Global prepare done") console.log("Global prepare done")
this._status.setData("Maps are rendered, building pdf") this._status.setData("Maps are rendered, building pdf")
new FixedUiElement("").AttachTo("extradiv")
console.log("Pages are prepared") console.log("Pages are prepared")
const doc = new jsPDF(mode, undefined, [width, height]) const doc = new jsPDF(mode, undefined, [width, height])
doc.advancedAPI((advancedApi) => { doc.advancedAPI((advancedApi) => {
const canvas = advancedApi.canvas
for (let i = 0; i < this._pages.length; i++) { for (let i = 0; i < this._pages.length; i++) {
console.log("Rendering page", i) console.log("Rendering page", i)
if (i > 0) { if (i > 0) {
@ -1022,28 +1095,6 @@ export class SvgToPdf {
return allTranslations return allTranslations
} }
/**
* Prepares all the minimaps
*/
public async Prepare(language1: string): Promise<SvgToPdf> {
for (const page of this._pages) {
await page.Prepare()
await page.PrepareLanguage(language1)
}
return this
}
public async PrepareLanguages(languages: string[]): Promise<boolean> {
for (const page of this._pages) {
// Load all languages at once.
// We don't parallelize the pages, as they'll probably reload the same languages anyway (and they are cached)
await Promise.all(
languages.map(async (language) => await page.PrepareLanguage(language))
)
}
return true
}
getTranslation(translationKey: string, language: string, strict: boolean = false) { getTranslation(translationKey: string, language: string, strict: boolean = false) {
for (const page of this._pages) { for (const page of this._pages) {
const tr = page.extractTranslation(translationKey, language, strict) const tr = page.extractTranslation(translationKey, language, strict)
@ -1057,4 +1108,15 @@ export class SvgToPdf {
} }
return undefined return undefined
} }
/**
* Prepares all the minimaps
*/
private async Prepare(language1: string): Promise<SvgToPdf> {
for (const page of this._pages) {
await page.Prepare()
await page.PrepareLanguage(language1)
}
return this
}
} }

View file

@ -0,0 +1,189 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="297mm"
height="420mm"
viewBox="0 0 297 420"
version="1.1"
id="svg5"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="CurrentMapWithHeaderA3.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs33">
<rect
x="41.54771"
y="103.43336"
width="118.76163"
height="107.62454"
id="rect19815" />
<rect
x="730.99915"
y="857.75903"
width="646.31287"
height="26.69614"
id="rect10143" />
</defs>
<sodipodi:namedview
id="namedview7"
pagecolor="#ffe1d9"
bordercolor="#111111"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="1"
inkscape:pagecheckerboard="1"
inkscape:document-units="mm"
showgrid="false"
showguides="true"
inkscape:guide-bbox="true"
inkscape:zoom="0.48119622"
inkscape:cx="338.73915"
inkscape:cy="566.29705"
inkscape:window-width="1920"
inkscape:window-height="995"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer2"
inkscape:snap-global="false" />
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="bg"
style="display:inline">
<rect
style="fill:none;stroke:#000000;stroke-width:1.34605;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect27895"
width="290.34955"
height="403.45847"
x="3.7768779"
y="6.4455185"
ry="0" />
<rect
style="fill:#ffffff;fill-opacity:0.456196;stroke:#000000;stroke-width:0.581828;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect28206"
width="203.38158"
height="35.362419"
x="6.3702731"
y="9.6101332"
ry="3.858089"
rx="4.3605742" />
<text
xml:space="preserve"
transform="scale(0.26458333)"
id="text4911"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;white-space:pre;shape-inside:url(#rect4913);fill:#000000;fill-opacity:1;stroke:#000000;stroke-opacity:1" />
<text
xml:space="preserve"
transform="scale(0.26458333)"
id="text10253"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;white-space:pre;shape-inside:url(#rect10255);fill:#000000;fill-opacity:1;stroke:#000000;stroke-opacity:1" />
<text
xml:space="preserve"
transform="matrix(0.26458333,0,0,0.26458333,14.472331,73.799994)"
id="text56705"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:0.25;font-family:sans-serif;white-space:pre;shape-inside:url(#rect56707);display:inline;fill:#000000;fill-opacity:1;stroke:#000000;stroke-opacity:1"><tspan
x="0"
y="0"
id="tspan891"><tspan
style="font-size:13.3333px;-inkscape-font-specification:'sans-serif, Normal'"
id="tspan889">$map(current)</tspan></tspan></text>
<text
xml:space="preserve"
transform="matrix(0.26458333,0,0,0.26458333,12.08115,27.672609)"
id="text3510"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:0;font-family:sans-serif;white-space:pre;shape-inside:url(#rect3512);fill:#000000;fill-opacity:1;stroke:#000000;stroke-opacity:1"><tspan
x="0"
y="0"
id="tspan895"><tspan
style="font-size:16px;line-height:1.05;-inkscape-font-specification:'sans-serif, Normal'"
id="tspan893">$general.pdf.attr
</tspan></tspan><tspan
x="0"
y="16.799999"
id="tspan899"><tspan
style="font-size:16px;line-height:1.05;-inkscape-font-specification:'sans-serif, Normal'"
id="tspan897">$general.pdf.attrBackground
</tspan></tspan><tspan
x="0"
y="35.692733"
id="tspan905"><tspan
style="font-size:16px;line-height:1.05;-inkscape-font-specification:'sans-serif, Normal'"
id="tspan901">$general.pdf.generatedWith</tspan><tspan
style="font-size:18.6667px;line-height:1.05;-inkscape-font-specification:'sans-serif, Normal'"
id="tspan903">
</tspan></tspan></text>
<text
xml:space="preserve"
transform="scale(0.26458333)"
id="text19136"
style="font-size:16px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';text-align:center;white-space:pre;shape-inside:url(#rect19138);fill:#000000;stroke:#000000;stroke-width:0.377953;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
<text
xml:space="preserve"
transform="matrix(0.26458333,0,0,0.26458333,-308.35032,184.63585)"
id="text10141"
style="font-size:16px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';text-align:end;white-space:pre;shape-inside:url(#rect10143);fill:#000000;fill-opacity:0.914749;stroke:#ff0000;stroke-width:3.77953;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none"><tspan
x="1182.4844"
y="871.91602"
id="tspan909"><tspan
style="fill-opacity:1;stroke:none"
id="tspan907">$general.pdf.versionInfo</tspan></tspan></text>
</g>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
style="display:inline">
<text
xml:space="preserve"
transform="scale(0.26458333)"
id="text62796"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;white-space:pre;shape-inside:url(#rect62798);fill:#000000;fill-opacity:1;stroke:none" />
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583"
x="102.80793"
y="16.415634"
id="text8611-8"><tspan
sodipodi:role="line"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:0.264583"
x="102.80793"
y="16.415634"
id="tspan8613-8" /></text>
<text
xml:space="preserve"
transform="scale(0.26458333)"
id="text81704"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;white-space:pre;shape-inside:url(#rect81706);fill:#000000;fill-opacity:1;stroke:none" />
<text
xml:space="preserve"
transform="matrix(0.26458333,0,0,0.26458333,11.738978,20.267151)"
id="text135030"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;white-space:pre;shape-inside:url(#rect135032);fill:#000000;fill-opacity:1;stroke:none"><tspan
x="0"
y="0"
id="tspan913"><tspan
style="font-weight:bold;font-size:34.6667px;-inkscape-font-specification:'sans-serif, Bold'"
id="tspan911">${title}</tspan></tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:10.5833px;line-height:1.25;font-family:sans-serif;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583"
x="105.86118"
y="116.25558"
id="text53309"><tspan
sodipodi:role="line"
id="tspan53307"
style="stroke-width:0.264583"
x="105.86118"
y="116.25558" /><tspan
sodipodi:role="line"
style="stroke-width:0.264583"
id="tspan53311"
x="105.86118"
y="129.4847" /></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View file

@ -23,9 +23,11 @@ async function testPdf() {
SvgToPdf.templates["flyer_a4"].pages.map((url) => Utils.download(url)) SvgToPdf.templates["flyer_a4"].pages.map((url) => Utils.download(url))
) )
console.log("Building svg") console.log("Building svg")
const pdf = new SvgToPdf("Test", svgs, {}) const pdf = new SvgToPdf("Test", svgs, {
freeComponentId:"extradiv"
})
new VariableUiElement(pdf.status).AttachTo("maindiv") new VariableUiElement(pdf.status).AttachTo("maindiv")
await pdf.ConvertSvg("nl") await pdf.ExportPdf("nl")
} }
testPdf().then((_) => console.log("All done")) testPdf().then((_) => console.log("All done"))

View file

@ -38,7 +38,6 @@
<body> <body>
<span class="absolute" id="belowmap" style="z-index: -1; visibility: hidden">Below</span>
<div class="h-full" id="maindiv"> <div class="h-full" id="maindiv">
<div id="default-main h-full"> <div id="default-main h-full">
<div class="w-full h-screen flex flex-col items-center justify-between p-8"> <div class="w-full h-screen flex flex-col items-center justify-between p-8">
@ -64,6 +63,7 @@
</div> </div>
</div> </div>
</div> </div>
<div id="belowmap" style="z-index: -1">Below</div>
<script> <script>