From 947e2a2e1996c6b6a39a6c7796e6441b4088569c Mon Sep 17 00:00:00 2001 From: Otfried Cheong Date: Sat, 10 Oct 2026 23:31:45 +0200 Subject: [PATCH] Implement export to pptx using JS pptxgenjs library. --- src/include/ipebase.h | 1 + src/include/ipeutils.h | 3 +- src/ipe-web/index.html | 5 +- src/ipe-web/package.json | 4 +- src/ipe-web/pnpm-lock.yaml | 136 ++++++++- src/ipe-web/src/ipe/ipeui.ts | 28 ++ src/ipe-web/src/ipe/modal.ts | 16 +- src/ipe-web/src/main.ts | 17 ++ src/ipe-web/src/write-pptx.ts | 361 +++++++++++++++++++++++ src/ipe/appui.cpp | 3 + src/ipe/lua/actions.lua | 52 ++++ src/ipe/lua/main.lua | 1 + src/ipe/uilua.cpp | 11 + src/ipecairo/Makefile | 3 +- src/ipecairo/ipecairopainter.cpp | 8 +- src/ipecairo/ipecairopainter.h | 14 +- src/ipecairo/ipereveal.cpp | 1 - src/ipecairo/ipereveal.h | 4 +- src/ipecairo/ipetojson.cpp | 472 +++++++++++++++++++++++++++++++ src/ipelib/ipebase.cpp | 24 ++ src/ipelib/ipeutils.cpp | 9 +- src/iperender/iperender.cpp | 23 +- 22 files changed, 1166 insertions(+), 30 deletions(-) create mode 100644 src/ipe-web/src/write-pptx.ts create mode 100644 src/ipecairo/ipetojson.cpp diff --git a/src/include/ipebase.h b/src/include/ipebase.h index b7013d5..532e0df 100644 --- a/src/include/ipebase.h +++ b/src/include/ipebase.h @@ -287,6 +287,7 @@ class Stream { Stream & operator<<(double d); void putHexByte(char b); void putXmlString(String s); + void putJsonString(String s); }; /*! \class ipe::TellStream diff --git a/src/include/ipeutils.h b/src/include/ipeutils.h index 121f15e..b489bb7 100644 --- a/src/include/ipeutils.h +++ b/src/include/ipeutils.h @@ -91,13 +91,14 @@ class A85Stream : public Stream { class Base64Stream : public Stream { public: - Base64Stream(Stream & stream); + Base64Stream(Stream & stream, bool noLineBreaks = false); virtual void putChar(char ch); virtual void close(); private: Stream & iStream; uint8_t iCh[3]; + int iNoLineBreaks; int iN; int iCol; }; diff --git a/src/ipe-web/index.html b/src/ipe-web/index.html index 90e30fa..f8307c0 100644 --- a/src/ipe-web/index.html +++ b/src/ipe-web/index.html @@ -163,8 +163,9 @@
Enter filename
-
The filename must not contain a slash (/) and must have - one of the following extensions: ipe pdf isy png jpg svg lua
+
The filename must not contain a + slash (/) and must have one of the following extensions: + ipe pdf isy png jpg svg lua pptx
Or upload a file: diff --git a/src/ipe-web/package.json b/src/ipe-web/package.json index 844e0d4..3bae63e 100644 --- a/src/ipe-web/package.json +++ b/src/ipe-web/package.json @@ -25,6 +25,8 @@ }, "dependencies": { "@melloware/coloris": "^0.24.0", - "sortablejs": "^1.15.3" + "sortablejs": "^1.15.3", + "jszip": "^3.10.2", + "pptxgenjs": "^4.0.1" } } diff --git a/src/ipe-web/pnpm-lock.yaml b/src/ipe-web/pnpm-lock.yaml index dc2c2ec..817bf66 100644 --- a/src/ipe-web/pnpm-lock.yaml +++ b/src/ipe-web/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@melloware/coloris': specifier: ^0.24.0 version: 0.24.2 + jszip: + specifier: ^3.10.2 + version: 3.10.2 + pptxgenjs: + specifier: ^4.0.1 + version: 4.0.1 sortablejs: specifier: ^1.15.3 version: 1.15.7 @@ -29,7 +35,7 @@ importers: version: 6.0.3 vite: specifier: ^8.0.7 - version: 8.3.0 + version: 8.3.0(@types/node@22.20.5) packages: @@ -188,9 +194,15 @@ packages: '@types/emscripten@1.41.6': resolution: {integrity: sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==} + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + '@types/sortablejs@1.15.9': resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -209,6 +221,29 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + https@1.0.0: + resolution: {integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==} + + image-size@1.2.1: + resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} + engines: {node: '>=16.x'} + hasBin: true + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + jszip@3.10.2: + resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -284,6 +319,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -295,11 +333,29 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + pptxgenjs@4.0.1: + resolution: {integrity: sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + rolldown@1.2.8: resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + sortablejs@1.15.7: resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} @@ -307,6 +363,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -316,6 +375,12 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite@8.3.0: resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -449,8 +514,14 @@ snapshots: '@types/emscripten@1.41.6': {} + '@types/node@22.20.5': + dependencies: + undici-types: 6.21.0 + '@types/sortablejs@1.15.9': {} + core-util-is@1.0.3: {} + detect-libc@2.1.2: {} fdir@6.5.0(picomatch@4.0.7): @@ -460,6 +531,29 @@ snapshots: fsevents@2.3.3: optional: true + https@1.0.0: {} + + image-size@1.2.1: + dependencies: + queue: 6.0.2 + + immediate@3.0.6: {} + + inherits@2.0.4: {} + + isarray@1.0.0: {} + + jszip@3.10.2: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.33.0: optional: true @@ -511,6 +605,8 @@ snapshots: nanoid@3.3.19: {} + pako@1.0.11: {} + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -521,6 +617,29 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pptxgenjs@4.0.1: + dependencies: + '@types/node': 22.20.5 + https: 1.0.0 + image-size: 1.2.1 + jszip: 3.10.2 + + process-nextick-args@2.0.1: {} + + queue@6.0.2: + dependencies: + inherits: 2.0.4 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + rolldown@1.2.8: dependencies: '@oxc-project/types': 0.149.0 @@ -542,10 +661,18 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.8 '@rolldown/binding-win32-x64-msvc': 1.2.8 + safe-buffer@5.1.2: {} + + setimmediate@1.0.5: {} + sortablejs@1.15.7: {} source-map-js@1.2.1: {} + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -553,7 +680,11 @@ snapshots: typescript@6.0.3: {} - vite@8.3.0: + undici-types@6.21.0: {} + + util-deprecate@1.0.2: {} + + vite@8.3.0(@types/node@22.20.5): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -561,4 +692,5 @@ snapshots: rolldown: 1.2.8 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 22.20.5 fsevents: 2.3.3 diff --git a/src/ipe-web/src/ipe/ipeui.ts b/src/ipe-web/src/ipe/ipeui.ts index 12163cf..2558fbd 100644 --- a/src/ipe-web/src/ipe/ipeui.ts +++ b/src/ipe-web/src/ipe/ipeui.ts @@ -90,6 +90,13 @@ export class IpeUi { externalConfiguration = ""; private internalConfiguration = ""; private _presentationWindow: Window | null = null; + _createSlides: + | (( + jsonData: string, + textConversion: string, + titleConversion: string, + ) => Promise) + | null = null; constructor( ipe: Ipe, @@ -708,6 +715,7 @@ export class IpeUi { "tablet_hints", "undo", "redo", + "export_pptx", ].includes(name) ) return; @@ -1247,4 +1255,24 @@ export class IpeUi { } } } + + async createSlides( + jsonFn: string, + outFn: string, + textConversion: string, + titleConversion: string, + ): Promise { + if (this._createSlides) { + const jsonData = window.ipeui.ipe.FS.readFile(jsonFn, { + encoding: "utf8", + }); + const data = await this._createSlides( + jsonData, + textConversion, + titleConversion, + ); + window.ipeui.ipe.FS.writeFile(outFn, data); + window.ipeui.modal.fileManager(); + } + } } diff --git a/src/ipe-web/src/ipe/modal.ts b/src/ipe-web/src/ipe/modal.ts index 753818c..d7b47b8 100644 --- a/src/ipe-web/src/ipe/modal.ts +++ b/src/ipe-web/src/ipe/modal.ts @@ -14,7 +14,12 @@ import "./modal.css"; import Sortable from "sortablejs"; import type { PopupMenu, PopupMenuResults } from "./popup-menu"; -type Directory = "documents" | "images" | "styles" | "ipelets"; +type Directory = + | "documents" + | "images" + | "styles" + | "ipelets" + | "presentations"; interface FileType { dir: Directory; @@ -30,6 +35,12 @@ const knownFileTypes: { [ext: string]: FileType } = { svg: { dir: "images", mimeType: "image/svg+xml" }, isy: { dir: "styles", mimeType: "application/xml" }, lua: { dir: "ipelets", mimeType: "text/plain" }, + html: { dir: "presentations", mimeType: "text/html" }, + pptx: { + dir: "presentations", + mimeType: + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + }, }; type ResumeCallback = (result: ResumeResult, threadRef: number) => void; @@ -58,8 +69,9 @@ export interface PageSelectorItem { function dirFromOptions(options: FileDialogOptions): Directory { const f = options.filters[0]; if (f.includes("*.ipe")) return "documents"; - if (f.includes("*.isy")) return "styles"; + if (f.includes("*.isy") || f === "reveal.htmljs") return "styles"; if (f.includes("*.lua")) return "ipelets"; + if (f.includes("*.html") || f.includes("*.pptx")) return "presentations"; return "images"; } diff --git a/src/ipe-web/src/main.ts b/src/ipe-web/src/main.ts index 604aca3..236c327 100644 --- a/src/ipe-web/src/main.ts +++ b/src/ipe-web/src/main.ts @@ -2,6 +2,7 @@ import instantiateIpe from "./ipe/ipe.js"; import { IpeUi } from "./ipe/ipeui"; import "./ipe/index.css"; import { buildInfo } from "./gitversion"; +import type { IpeDocument, TextConversion } from "./write-pptx"; declare global { interface Window { @@ -10,6 +11,20 @@ declare global { } } +async function createSlides( + jsonData: string, + textConversion: string, + titleConversion: string, +): Promise { + const doc: IpeDocument = JSON.parse(jsonData); + const { createPptx } = await import("./write-pptx"); + return await createPptx( + doc, + textConversion as TextConversion, + titleConversion as TextConversion, + ); +} + instantiateIpe({ printErr: console.log.bind(console), }).then(async (ipe: any) => { @@ -23,6 +38,8 @@ instantiateIpe({ "IPEDEBUG=1", ]); + ipeui._createSlides = createSlides; + ipe.FS.mkdir("/home/ipe", 0o777); ipe.FS.mkdir("/tmp/pages", 0o777); ipe.FS.mkdir("/tmp/latexrun", 0o777); diff --git a/src/ipe-web/src/write-pptx.ts b/src/ipe-web/src/write-pptx.ts new file mode 100644 index 0000000..3fb66f8 --- /dev/null +++ b/src/ipe-web/src/write-pptx.ts @@ -0,0 +1,361 @@ +import JSZip from "jszip"; +import PptxGenJS from "pptxgenjs"; + +export type TextConversion = "all" | "latex" | "labels" | "math" | "none"; + +interface IpePoint { + x: number; + y: number; + moveto?: boolean; + x1?: number; + y1?: number; + x2?: number; + y2?: number; +} + +type IpePathElement = IpePoint | { close: true }; + +interface IpeObject { + points?: IpePathElement[]; + fill?: PptxGenJS.ShapeFillProps; + line?: PptxGenJS.ShapeLineProps; +} + +interface IpeText { + text: string; + x: number; + y: number; + color: string; + width: number; + height: number; // above baseline + depth: number; // below baseline + minipage: boolean; + verticalAlignment: "top" | "bottom" | "center" | "baseline"; + horizontalAlignment: "left" | "right" | "hcenter" | "center"; + size: number; + style: string; +} + +interface IpeImage { + image: string; + x: number; + y: number; + w: number; + h: number; + format: "jpeg" | "png"; +} + +interface IpePage { + objects: (IpeObject | IpeText | IpeImage)[]; +} + +export interface IpeDocument { + title?: string; + author?: string; + subject?: string; + keywords?: string; + created?: string; + modified?: string; + width: number; + height: number; + originX: number; + originY: number; + pages: IpePage[]; +} + +function pdfDateToW3cDate(date: string): string { + const match = + /^D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(Z|[+-]\d{2}'?\d{2}'?)?$/.exec( + date, + ); + if (!match) throw new Error(`Invalid PDF date: ${date}`); + const [, year, month, day, hour, minute, second, zone] = match; + let timezone = zone ?? ""; + if (timezone && timezone !== "Z") { + timezone = timezone.replace(/'/g, ""); + timezone = `${timezone.slice(0, 3)}:${timezone.slice(3)}`; + } + return `${year}-${month}-${day}T${hour}:${minute}:${second}${timezone}`; +} + +function escapeXml(text: string): string { + return text.replace( + /[&<>"']/g, + (char) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[char]!, + ); +} + +async function updateCoreProperties( + data: Uint8Array, + metadata: IpeDocument, +): Promise { + const zip = await JSZip.loadAsync(data); + const coreFile = zip.file("docProps/core.xml"); + if (!coreFile) throw new Error("PPTX is missing docProps/core.xml"); + + let xml = await coreFile.async("string"); + if (metadata.keywords !== undefined) { + const keywords = `${escapeXml(metadata.keywords)}`; + xml = xml.replace( + /([\s\S]*?<\/dc:creator>)/, + (creator) => `${creator}${keywords}`, + ); + } + for (const [field, date] of [ + ["created", metadata.created], + ["modified", metadata.modified], + ] as const) { + if (!date) continue; + const value = escapeXml(pdfDateToW3cDate(date)); + const tag = field === "created" ? "dcterms:created" : "dcterms:modified"; + xml = xml.replace( + new RegExp(`(<${tag}\\b[^>]*>)[\\s\\S]*?()`), + `$1${value}$2`, + ); + } + + zip.file("docProps/core.xml", xml); + return zip.generateAsync({ type: "uint8array" }); +} + +type ShapePoint = NonNullable[number]; + +function addIpeText( + slide: PptxGenJS.Slide, + t: IpeText, + originX: number, + originY: number, +): void { + // (x, y) is the bottom-left corner of the bounding box + const total = t.height + t.depth; + const top = t.y + total; + const unwrappedText = + /^\\PageTitle\{([\s\S]*)\}$/.exec(t.text)?.[1] ?? t.text; + const sourceText = + t.style === "item" && !/^\\item\b/.test(unwrappedText) + ? `\\item ${unwrappedText}` + : unwrappedText; + const paragraphs: { text: string; bullet: boolean }[] = []; + let paragraphText = ""; + let bulletNext = false; + const flushParagraph = () => { + const text = paragraphText.replace(/\n/g, " ").trim(); + if (text) paragraphs.push({ text, bullet: bulletNext }); + paragraphText = ""; + bulletNext = false; + }; + for (const part of sourceText.split(/(\\\\|\\item\b|\\par\b|\n\s*\n)/)) { + if (part === "\\item") { + flushParagraph(); + bulletNext = true; + } else if (part === "\\\\" || part === "\\par" || /^\n\s*\n$/.test(part)) { + flushParagraph(); + } else { + paragraphText += part; + } + } + flushParagraph(); + + const text: string | PptxGenJS.TextProps[] = paragraphs.some((p) => p.bullet) + ? paragraphs.map((p, index) => ({ + text: p.text, + options: { + ...(p.bullet ? { bullet: true } : {}), + breakLine: index < paragraphs.length - 1, + }, + })) + : paragraphs.map((p) => p.text).join("\n"); + + slide.addText(text, { + x: (originX + t.x) / 72, + y: (originY - top - 0.2 * t.size) / 72, + w: Math.max(t.width, 1) / 72, + h: Math.max(total, 1) / 72, + // Scale font size down slightly to better fit the bounding box + fontSize: 0.95 * t.size, + color: t.color, + align: + t.horizontalAlignment === "hcenter" || + (t.minipage && t.style === "center") + ? "center" + : t.horizontalAlignment, + valign: "top", + margin: 0, + wrap: t.minipage, + }); +} + +function addIpeImage( + slide: PptxGenJS.Slide, + t: IpeImage, + originX: number, + originY: number, +): void { + // (x, y) is the top-left corner of the image + slide.addImage({ + data: `data:image/${t.format};base64,${t.image}`, + x: (originX + t.x) / 72, + y: (originY - t.y) / 72, + w: Math.max(t.w, 1) / 72, + h: Math.max(t.h, 1) / 72, + }); +} + +function shouldConvert( + obj: IpeText, + textConversion: TextConversion, + titleConversion: TextConversion, +): boolean { + const titleText = /^\\PageTitle\{([\s\S]*)\}$/.exec(obj.text)?.[1]; + const latexText = (titleText ?? obj.text).replace( + /\\\\|\\par\b|\\item\b/g, + "", + ); + if (obj.text.startsWith("\\PageTitle")) { + if (titleConversion === "all") return true; + return ( + titleConversion === "latex" && + (latexText.includes("$") || latexText.includes("\\")) + ); + } + if (textConversion === "all") return true; + if (textConversion === "latex") + return ( + obj.style === "math" || + latexText.includes("$") || + latexText.includes("\\") + ); + if (obj.minipage) return false; + return ( + textConversion === "labels" || + (textConversion === "math" && obj.style === "math") + ); +} + +function addIpeObjects( + slide: PptxGenJS.Slide, + page: IpePage, + textConversion: TextConversion, + titleConversion: TextConversion, + originX: number, + originY: number, +): void { + const tx = (x: number) => (originX + x) / 72; + const ty = (y: number) => (originY - y) / 72; + + let inTextObject = false; + for (const obj of page.objects) { + if ("text" in obj) { + if (!shouldConvert(obj, textConversion, titleConversion)) { + addIpeText(slide, obj, originX, originY); + inTextObject = true; + } + continue; + } + if ("endtext" in obj) { + inTextObject = false; + continue; + } + + if (inTextObject) continue; + + if ("image" in obj) { + addIpeImage(slide, obj, originX, originY); + continue; + } + + if (!obj.points || obj.points.length === 0) continue; + + // create custom path + let xmin = Infinity, + ymin = Infinity, + xmax = -Infinity, + ymax = -Infinity; + const extend = (x: number, y: number) => { + xmin = Math.min(xmin, tx(x)); + xmax = Math.max(xmax, tx(x)); + ymin = Math.min(ymin, ty(y)); + ymax = Math.max(ymax, ty(y)); + }; + for (const p of obj.points) { + if ("close" in p) continue; + extend(p.x, p.y); + if (p.x1 !== undefined && p.y1 !== undefined) extend(p.x1, p.y1); + if (p.x2 !== undefined && p.y2 !== undefined) extend(p.x2, p.y2); + } + if (xmin === Infinity) continue; + + const rx = (x: number) => tx(x) - xmin; + const ry = (y: number) => ty(y) - ymin; + const points: ShapePoint[] = obj.points.map((p): ShapePoint => { + if ("close" in p) return { close: true }; + if ( + p.x1 !== undefined && + p.y1 !== undefined && + p.x2 !== undefined && + p.y2 !== undefined + ) { + return { + x: rx(p.x), + y: ry(p.y), + curve: { + type: "cubic", + x1: rx(p.x1), + y1: ry(p.y1), + x2: rx(p.x2), + y2: ry(p.y2), + }, + }; + } + return { x: rx(p.x), y: ry(p.y), moveTo: p.moveto }; + }); + + // Zero extent (e.g. horizontal line) would give a degenerate path coordinate system. + const minExtent = 0.001; + // "custGeom" (pptx.shapes.CUSTOM_GEOMETRY) is missing from pptxgenjs's typings. + slide.addShape("custGeom" as PptxGenJS.SHAPE_NAME, { + x: xmin, + y: ymin, + w: Math.max(xmax - xmin, minExtent), + h: Math.max(ymax - ymin, minExtent), + points, + fill: obj.fill ?? { type: "none" }, + line: obj.line ?? { type: "none" }, + }); + } +} + +export async function createPptx( + doc: IpeDocument, + textConversion: TextConversion, + titleConversion: TextConversion, +): Promise { + const { width, height, originX, originY } = doc; + + const pptx: PptxGenJS = new PptxGenJS(); + pptx.defineLayout({ name: "IPE", width: width / 72, height: height / 72 }); // pptxgenjs uses inches + pptx.layout = "IPE"; + if (doc.title !== undefined) pptx.title = doc.title; + if (doc.author !== undefined) pptx.author = doc.author; + if (doc.subject !== undefined) pptx.subject = doc.subject; + + for (const page of doc.pages) + addIpeObjects( + pptx.addSlide(), + page, + textConversion, + titleConversion, + originX, + originY, + ); + + const data = (await pptx.write({ outputType: "uint8array" })) as Uint8Array; + return updateCoreProperties(data, doc); +} diff --git a/src/ipe/appui.cpp b/src/ipe/appui.cpp index 4ecdd6e..bb33594 100644 --- a/src/ipe/appui.cpp +++ b/src/ipe/appui.cpp @@ -154,6 +154,9 @@ void AppUiBase::buildMenus() { addItem(EFileMenu, "Export as PNG", "export_png"); addItem(EFileMenu, "Export as EPS", "export_eps"); addItem(EFileMenu, "Export as SVG", "export_svg"); +#ifdef IPEUI_JS + addItem(EFileMenu, "Export as PPTX", "export_pptx"); +#endif addItem(EFileMenu); addItem(EFileMenu, "Insert image", "insert_image"); addItem(EFileMenu); diff --git a/src/ipe/lua/actions.lua b/src/ipe/lua/actions.lua index 0927a96..ed063b0 100644 --- a/src/ipe/lua/actions.lua +++ b/src/ipe/lua/actions.lua @@ -963,6 +963,58 @@ function MODEL:action_export_reveal() end end +function MODEL:action_export_pptx() + if not self.file_name then + self:warning("Export failed", "Document does not have a filename") + return + end + local f + if self.file_name:sub(1, 20) == "/home/ipe/documents/" then + f = "/home/ipe/presentations/" .. self.file_name:sub(21, -5) .. ".pptx" + else + f = "/home/ipe/presentations/unknown.pptx" + end + local d = ipeui.Dialog(self.ui:win(), "Ipe: Export PPTX") + d:add("label0", "label", { label="Exporting to" }, 1, 1) + d:add("fname", "label", { label=f }, 1, 2) + d:add("label1", "label", { label="Export text as graphics?" }, 2, 1) + d:add("textconversion", "combo", { + "All text as graphics", + "If text contains LaTeX", + "Labels and math", + "Math", + "Preserve all text as text" + }, 2, 2) + d:add("label2", "label", { label="Export page titles as graphics?" }, 3, 1) + d:add("titleconversion", "combo", { + "All titles as graphics", + "If title contains LaTeX", + "Preserve all titles as text" + }, 3, 2) + d:add("label3", "label", { label="Which views?" }, 4, 1) + d:add("views", "combo", { + "All views", + "Only marked views on marked pages", + }, 4, 2) + d:set("textconversion", 2) + d:set("titleconversion", 2) + d:addButton("ok", "&Ok", "accept") + d:addButton("cancel", "&Cancel", "reject") + if not d:execute(prefs.latexlog_size) then return end + local onlyMarkedViews = d:get("views") == 2 + local textConversions = {"all", "latex", "label", "math", "none"} + local textConversion = textConversions[d:get("textconversion")] + local titleConversions = {"all", "latex", "none"} + local titleConversion = titleConversions[d:get("titleconversion")] + print("Creating Json", f, textConversion, titleConversion, onlyMarkedViews) + local jsonName = "/tmp/document.json" + if not self.ui:createJson(self.doc, jsonName, onlyMarkedViews) then + self:warning("Export failed", "Could not write Json") + return + end + self.ui.js("createSlides", jsonName, f, textConversion, titleConversion) +end + function MODEL:action_insert_image() if not self.insert_image_dir and self.file_name then self.insert_image_dir = self.file_name:match(prefs.dir_pattern) diff --git a/src/ipe/lua/main.lua b/src/ipe/lua/main.lua index 11a79e3..eb32b8b 100644 --- a/src/ipe/lua/main.lua +++ b/src/ipe/lua/main.lua @@ -232,6 +232,7 @@ end filter_png = { "Images (*.png)", "*.png" } filter_eps = { "Postscript files (*.eps)", "*.eps" } filter_svg = { "SVG files (*.svg)", "*.svg" } +filter_pptx = { "PPTX files (*.pptx)", "*.pptx" } ---------------------------------------------------------------------- -- This function is called to launch a file on MacOS X diff --git a/src/ipe/uilua.cpp b/src/ipe/uilua.cpp index 5a04d68..330506c 100644 --- a/src/ipe/uilua.cpp +++ b/src/ipe/uilua.cpp @@ -668,6 +668,16 @@ static int appui_renderPage(lua_State * L) { return 0; } +static int appui_createJson(lua_State * L) { + // AppUiBase **ui = check_appui(L, 1); // not used + Document ** doc = check_document(L, 2); + const char * dst = luaL_checkstring(L, 3); + bool onlyMarkedViews = lua_toboolean(L, 4); + Reveal reveal(*doc, 0, (*doc)->countPages() - 1); + lua_pushboolean(L, reveal.createJson(dst, onlyMarkedViews)); + return 1; +} + static int appui_createPresentation(lua_State * L) { // AppUiBase **ui = check_appui(L, 1); // not used Document ** doc = check_document(L, 2); @@ -745,6 +755,7 @@ static const struct luaL_Reg appui_methods[] = { {"setRecentFiles", appui_setRecentFiles}, {"showTool", appui_showTool}, {"renderPage", appui_renderPage}, + {"createJson", appui_createJson}, {"createPresentation", appui_createPresentation}, #ifdef IPEUI_JS {"js", appui_jsCall}, diff --git a/src/ipecairo/Makefile b/src/ipecairo/Makefile index 32c3937..3078134 100644 --- a/src/ipecairo/Makefile +++ b/src/ipecairo/Makefile @@ -23,7 +23,8 @@ sources = \ ipecairopainter.cpp \ ipefonts.cpp \ ipethumbs.cpp \ - ipereveal.cpp + ipereveal.cpp \ + ipetojson.cpp ifdef IPEWASM $(TARGET): $(objects) diff --git a/src/ipecairo/ipecairopainter.cpp b/src/ipecairo/ipecairopainter.cpp index 37d72ec..81287fa 100644 --- a/src/ipecairo/ipecairopainter.cpp +++ b/src/ipecairo/ipecairopainter.cpp @@ -55,7 +55,7 @@ extern bool dctDecode(Buffer dctData, Buffer pixelData); // -------------------------------------------------------------------- -static void cairoMatrix(cairo_matrix_t & cm, const Matrix & m) { +void CairoPainter::cairoMatrix(cairo_matrix_t & cm, const Matrix & m) { cm.xx = m.a[0]; cm.yx = m.a[1]; cm.xy = m.a[2]; @@ -64,7 +64,7 @@ static void cairoMatrix(cairo_matrix_t & cm, const Matrix & m) { cm.y0 = m.a[5]; } -static void cairoTransform(cairo_t * cr, const Matrix & m) { +void CairoPainter::cairoTransform(cairo_t * cr, const Matrix & m) { cairo_matrix_t cm; cairoMatrix(cm, m); cairo_transform(cr, &cm); @@ -215,8 +215,8 @@ static bool applyPngPrediction(Buffer & data, int width, int height, int compone return true; } -static void drawImage(cairo_t * cr, const PdfDict * d, const PdfResourceBase * r, - double opacity, bool filterBest) { +void CairoPainter::drawImage(cairo_t * cr, const PdfDict * d, const PdfResourceBase * r, + double opacity, bool filterBest) { // ipeDebug("Image: %s", d->dictRepr().z()); int width = d->getInteger("Width"); int height = d->getInteger("Height"); diff --git a/src/ipecairo/ipecairopainter.h b/src/ipecairo/ipecairopainter.h index 92726a0..508ae62 100644 --- a/src/ipecairo/ipecairopainter.h +++ b/src/ipecairo/ipecairopainter.h @@ -72,9 +72,10 @@ class CairoPainter : public Painter { virtual void doDrawBitmap(Bitmap bitmap) override; virtual void doDrawText(const Text * text) override; -private: + virtual void drawGlyphs(std::vector & glyphs); + +protected: const PdfDict * findResource(String kind, String name); - void drawGlyphs(std::vector & glyphs); void collectGlyphs(String s, std::vector & glyphs, Vector & textPos); void execute(const PdfDict * stream, const PdfDict * resources, bool applyMatrix = true); @@ -113,11 +114,16 @@ class CairoPainter : public Painter { void opQ(); void opre(); void opn(); - void opStrokeFill(bool close, bool fill, bool stroke, bool eofill); + virtual void opStrokeFill(bool close, bool fill, bool stroke, bool eofill); void opsh(); void createPattern(); -private: + static void cairoMatrix(cairo_matrix_t & cm, const Matrix & m); + static void cairoTransform(cairo_t * cr, const Matrix & m); + static void drawImage(cairo_t * cr, const PdfDict * d, const PdfResourceBase * r, + double opacity, bool filterBest); + +protected: Fonts * iFonts; cairo_t * iCairo; diff --git a/src/ipecairo/ipereveal.cpp b/src/ipecairo/ipereveal.cpp index cdd5e82..9a12fa1 100644 --- a/src/ipecairo/ipereveal.cpp +++ b/src/ipecairo/ipereveal.cpp @@ -69,7 +69,6 @@ String Reveal::createSVG() { &stream_writer, (void *)&svgWriter, iWidth, iHeight); cairo_t * cc = cairo_create(surface); - // cairo_translate(cc, -offset.x, -offset.y); cairo_set_tolerance(cc, tolerance); diff --git a/src/ipecairo/ipereveal.h b/src/ipecairo/ipereveal.h index 7f5bea5..a46baa9 100644 --- a/src/ipecairo/ipereveal.h +++ b/src/ipecairo/ipereveal.h @@ -41,18 +41,18 @@ namespace ipe { class Reveal { public: - enum TargetFormat { ESVG, EPNG, EPS, EPDF }; - Reveal(const Document * doc, int fromPage, int toPage); int width() const { return iWidth; } int height() const { return iHeight; } bool createPresentation(const char * destination, String htmlTemplate); + bool createJson(const char * destination, bool onlyMarked); private: String createSVG(); bool writeSlides(Stream & out, String svg); + void createJsonView(Stream & out, int pageNo, int view, bool & sep); private: const Document * iDoc; diff --git a/src/ipecairo/ipetojson.cpp b/src/ipecairo/ipetojson.cpp new file mode 100644 index 0000000..0847231 --- /dev/null +++ b/src/ipecairo/ipetojson.cpp @@ -0,0 +1,472 @@ +// -------------------------------------------------------------------- +// Create a json document with Ipe drawing +// -------------------------------------------------------------------- +/* + + This file is part of the extensible drawing editor Ipe. + Copyright (c) 1993-2026 Otfried Cheong + + Ipe is free software; you can redistribute it and/or modify it + under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 3 of the License, or + (at your option) any later version. + + As a special exception, you have permission to link Ipe with the + CGAL library and distribute executables, as long as you follow the + requirements of the Gnu General Public License in regard to all of + the software in the executable aside from CGAL. + + Ipe is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public + License for more details. + + You should have received a copy of the GNU General Public License + along with Ipe; if not, you can find it at + "http://www.gnu.org/copyleft/gpl.html", or write to the Free + Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + +*/ + +#include "ipereveal.h" + +#include "ipeutils.h" + +#include "ipecairopainter.h" + +#include +#include + +#include + +using namespace ipe; + +// -------------------------------------------------------------------- + +class JsonPainter : public CairoPainter { +public: + JsonPainter(Stream & ss, const Cascade * sheet, Fonts * fonts, cairo_t * cc); + +protected: + virtual void doNewPath(); + virtual void doMoveTo(const Vector & v); + virtual void doLineTo(const Vector & v); + virtual void doCurveTo(const Vector & v1, const Vector & v2, const Vector & v3); + virtual void doClosePath(); + virtual void doDrawPath(TPathMode mode); + virtual void doDrawText(const Text * text); + virtual void doDrawBitmap(Bitmap bitmap); + virtual void doDrawArc(const Arc & arc); + + virtual void drawGlyphs(std::vector & glyphs); + void emitPath(cairo_path_t * path, bool stroke, bool fill); + virtual void opStrokeFill(bool close, bool fill, bool stroke, bool eofill); + +private: + Stream & iOut; + bool iObjectsWritten; + bool iPathElementWritten; +}; + +JsonPainter::JsonPainter(Stream & ss, const Cascade * sheet, Fonts * fonts, cairo_t * cc) + : CairoPainter(sheet, fonts, cc, 1.0, true, true) + , iOut{ss} { + iObjectsWritten = false; +} + +void JsonPainter::doNewPath() { + if (iObjectsWritten) iOut << ",\n"; + iOut << "{ \"points\": [\n"; + iPathElementWritten = false; +} + +void JsonPainter::doMoveTo(const Vector & v) { + if (iPathElementWritten) iOut << ",\n"; + iOut << "{ \"x\": " << v.x << ", \"y\": " << v.y << ", \"moveto\": true}"; + iPathElementWritten = true; +} + +void JsonPainter::doLineTo(const Vector & v) { + if (iPathElementWritten) iOut << ",\n"; + iOut << "{ \"x\": " << v.x << ", \"y\": " << v.y << "}"; + iPathElementWritten = true; +} + +void JsonPainter::doCurveTo(const Vector & v1, const Vector & v2, const Vector & v3) { + if (iPathElementWritten) iOut << ",\n"; + iOut << "{ \"x\": " << v3.x << ", \"y\": " << v3.y << ", \"x1\": " << v1.x + << ", \"y1\": " << v1.y << ", \"x2\": " << v2.x << ", \"y2\": " << v2.y << "}"; + iPathElementWritten = true; +} + +void JsonPainter::doClosePath() { + if (iPathElementWritten) iOut << ",\n"; + iOut << "{ \"close\": true}"; + iPathElementWritten = true; +} + +void JsonPainter::doDrawPath(TPathMode mode) { + iOut << "\n]"; + if (mode >= EStrokedAndFilled) { + Color fillColor = fill(); + int transparency = int(100 * (1.0 - opacity().toDouble())); + int red = int(255 * fillColor.iRed.toDouble()); + int green = int(255 * fillColor.iGreen.toDouble()); + int blue = int(255 * fillColor.iBlue.toDouble()); + iOut << ",\n\"fill\": { \"color\": \""; + iOut.putHexByte(red); + iOut.putHexByte(green); + iOut.putHexByte(blue); + iOut << "\""; + if (transparency != 0) iOut << ", \"transparency\": " << transparency; + iOut << "}"; + } + if (mode <= EStrokedAndFilled) { + double lw = pen().toDouble(); + Color strokeColor = stroke(); + int transparency = int(100 * (1.0 - strokeOpacity().toDouble())); + int red = int(255 * strokeColor.iRed.toDouble()); + int green = int(255 * strokeColor.iGreen.toDouble()); + int blue = int(255 * strokeColor.iBlue.toDouble()); + iOut << ",\n\"line\": { \"color\": \""; + iOut.putHexByte(red); + iOut.putHexByte(green); + iOut.putHexByte(blue); + iOut << "\""; + if (transparency != 0) iOut << ", \"transparency\": " << transparency; + String s = dashStyle(); + if (s == "[4] 0") + iOut << ", \"dashType\": \"dash\""; + else if (s == "[1 3] 0") + iOut << ", \"dashType\": \"lgDash\""; + else if (s == "[4 2 1 2] 0") + iOut << ", \"dashType\": \"dashDot\""; + else if (s == "[4 2 1 2 1 2] 0") + iOut << ", \"dashType\": \"lgDashDotDot\""; + iOut << ", \"width\": " << lw << "}"; + } + iOut << "\n}"; + iObjectsWritten = true; +} + +void JsonPainter::doDrawArc(const Arc & arc) { Painter::doDrawArc(arc); } + +static const char * const horizontal_alignment_names[] = {"left", "right", "hcenter"}; + +static const char * const vertical_alignment_names[] = {"bottom", "baseline", "top", + "vcenter"}; + +static const char * const latex_sizes[] = {"\\tiny", "\\footnotesize", "\\small", + "\\normalsize", "\\large", "\\Large", + "\\LARGE", "\\huge", "\\Huge"}; + +static const int latex_point_sizes[] = {5, 8, 9, 10, 12, 14, 17, 20, 25}; + +void JsonPainter::doDrawText(const Text * text) { + if (iObjectsWritten) iOut << ",\n"; + iObjectsWritten = true; + Vector pos = matrix() * Vector(0, 0); + iOut << "{ \"text\": \""; + iOut.putJsonString(text->text()); + iOut << "\", \"x\": " << pos.x << ", \"y\": " << pos.y; + Color color = stroke(); + int transparency = int(100 * (1.0 - opacity().toDouble())); + int red = int(255 * color.iRed.toDouble()); + int green = int(255 * color.iGreen.toDouble()); + int blue = int(255 * color.iBlue.toDouble()); + iOut << ", \"color\": \""; + iOut.putHexByte(red); + iOut.putHexByte(green); + iOut.putHexByte(blue); + iOut << "\""; + if (transparency != 0) iOut << ", \"transparency\": " << transparency; + iOut << ", \"width\": " << text->width(); + iOut << ", \"height\": " << text->height(); + iOut << ", \"depth\": " << text->depth(); + iOut << ", \"minipage\": " << (text->isMinipage() ? "true" : "false"); + iOut << ", \"verticalAlignment\": \"" + << vertical_alignment_names[text->verticalAlignment()] << "\""; + iOut << ", \"horizontalAlignment\": \"" + << horizontal_alignment_names[text->horizontalAlignment()] << "\""; + iOut << ", \"style\": \"" << text->style().string() << "\""; + Attribute size = iCascade->find(ETextSize, text->size()); + double stretch = iCascade->find(ETextStretch, text->size()).number().toDouble(); + double fontSize = 10 * stretch; + if (size.isNumber()) { + fontSize = size.number().toDouble() * stretch; + } else if (size.isString()) { + String s = size.string(); + for (size_t i = 0; i < sizeof(latex_point_sizes) / sizeof(int); ++i) { + if (s == latex_sizes[i]) { + fontSize = latex_point_sizes[i] * stretch; + break; + } + } + } + iOut << ", \"size\": " << fontSize << "}"; + CairoPainter::doDrawText(text); + iOut << ",\n{\"endtext\": true}"; +} + +void JsonPainter::drawGlyphs(std::vector & glyphs) { + PdfState & ps = iPdfState.back(); + if (!ps.iFont) return; + + Matrix m = iTextMatrix + * Matrix(ps.iFontSize * ps.iHorizontalScaling, 0, 0, ps.iFontSize, 0, + ps.iTextRise) + * Linear(1, 0, 0, -1); + + cairo_matrix_t matrix; + cairoMatrix(matrix, m); + + if (ps.iFont->type() == FontType::Type3) return; + + cairo_save(iCairo); + cairo_set_font_face(iCairo, ps.iFont->cairoFont()); + cairo_set_font_matrix(iCairo, &matrix); + cairo_new_path(iCairo); + cairo_glyph_path(iCairo, glyphs.data(), glyphs.size()); + // copy_path returns current user space; we want device space + cairo_identity_matrix(iCairo); + cairo_path_t * path = cairo_copy_path(iCairo); + emitPath(path, false, true); + cairo_path_destroy(path); + cairo_new_path(iCairo); + cairo_restore(iCairo); +} + +void JsonPainter::emitPath(cairo_path_t * path, bool stroke, bool fill) { + if (iObjectsWritten) iOut << ",\n"; + iObjectsWritten = true; + iOut << "{ \"points\": [\n"; + for (int i = 0; i < path->num_data;) { + const cairo_path_data_t * data = &path->data[i]; + const auto type = data->header.type; + + if (i > 0) iOut << ","; + + switch (type) { + case CAIRO_PATH_MOVE_TO: { + const auto & p = data[1].point; + iOut << "{ \"x\": " << p.x << ", \"y\": " << p.y << ", \"moveto\": true}"; + break; + } + case CAIRO_PATH_LINE_TO: { + const auto & p = data[1].point; + iOut << "{ \"x\": " << p.x << ", \"y\": " << p.y << "}"; + break; + } + case CAIRO_PATH_CURVE_TO: { + const auto & v1 = data[1].point; + const auto & v2 = data[2].point; + const auto & v3 = data[3].point; + iOut << "{ \"x\": " << v3.x << ", \"y\": " << v3.y << ", \"x1\": " << v1.x + << ", \"y1\": " << v1.y << ", \"x2\": " << v2.x << ", \"y2\": " << v2.y + << "}"; + break; + } + case CAIRO_PATH_CLOSE_PATH: iOut << "{ \"close\": true}"; break; + } + + i += data->header.length; + } + iOut << "]"; + PdfState & ps = iPdfState.back(); + if (fill) { + int transparency = int(100 * (1.0 - ps.iFillOpacity)); + int red = int(255 * ps.iFillRgb[0]); + int green = int(255 * ps.iFillRgb[1]); + int blue = int(255 * ps.iFillRgb[2]); + iOut << ", \"fill\": { \"color\": \""; + iOut.putHexByte(red); + iOut.putHexByte(green); + iOut.putHexByte(blue); + iOut << "\""; + if (transparency != 0) iOut << ", \"transparency\": " << transparency; + iOut << "}"; + } + if (stroke) { + int transparency = int(100 * (1.0 - ps.iStrokeOpacity)); + int red = int(255 * ps.iStrokeRgb[0]); + int green = int(255 * ps.iStrokeRgb[1]); + int blue = int(255 * ps.iStrokeRgb[2]); + iOut << ", \"line\": { \"color\": \""; + iOut.putHexByte(red); + iOut.putHexByte(green); + iOut.putHexByte(blue); + iOut << "\""; + if (transparency != 0) iOut << ", \"transparency\": " << transparency; + iOut << "}"; + } + iOut << "}"; +} + +void JsonPainter::opStrokeFill(bool close, bool fill, bool stroke, bool eofill) { + if (close) cairo_close_path(iCairo); + cairo_save(iCairo); + cairo_identity_matrix(iCairo); + cairo_path_t * path = cairo_copy_path(iCairo); + emitPath(path, stroke, fill); + cairo_path_destroy(path); + cairo_restore(iCairo); +} + +static cairo_status_t stream_writer(void * closure, const unsigned char * data, + unsigned int length) { + Stream * ss = (Stream *)closure; + ss->putRaw((const char *)data, length); + return CAIRO_STATUS_SUCCESS; +} + +void JsonPainter::doDrawBitmap(Bitmap bitmap) { + if (iObjectsWritten) iOut << ",\n"; + iObjectsWritten = true; + Vector tl = matrix() * Vector(0.0, 1.0); + Vector tr = matrix() * Vector(1.0, 1.0); + Vector bl = matrix() * Vector(0.0, 0.0); + iOut << "{\"image\": \""; + Base64Stream b64(iOut, true); + if (bitmap.isJpeg()) { + Buffer buffer = bitmap.embed().first; + const char * p = buffer.data(); + const char * fin = p + buffer.size(); + while (p != fin) b64.putChar(*p++); + } else { + cairo_surface_t * image = cairo_image_surface_create( + CAIRO_FORMAT_ARGB32, bitmap.width(), bitmap.height()); + memcpy(cairo_image_surface_get_data(image), bitmap.pixelData().data(), + 4 * bitmap.width() * bitmap.height()); + cairo_surface_mark_dirty(image); + cairo_surface_flush(image); + cairo_surface_write_to_png_stream(image, &stream_writer, (void *)&b64); + } + b64.close(); + iOut << "\", \"x\": " << tl.x << ", \"y\": " << tl.y; + iOut << ", \"w\": " << (tr - tl).len(); + iOut << ", \"h\": " << (bl - tl).len(); + iOut << ", \"format\": " << (bitmap.isJpeg() ? "\"jpeg\"" : "\"png\""); + int transparency = int(100 * (1.0 - opacity().toDouble())); + if (transparency != 0) iOut << ", \"transparency\": " << transparency; + iOut << "}"; +} + +// -------------------------------------------------------------------- + +static cairo_status_t dummy_writer(void * /* closure */, const unsigned char * /* data */, + unsigned int /* length */) { + // dummy + return CAIRO_STATUS_SUCCESS; +} + +void Reveal::createJsonView(Stream & out, int pageNo, int view, bool & sep) { + if (sep) out << ",\n"; + sep = true; + + Attribute variant = iDoc->variant(); + const Page * page = iDoc->page(pageNo); + + out << "{\n\"page\": " << pageNo + 1 << ", \"view\": " << view + 1 + << ", \"objects\": [\n"; + + cairo_surface_t * surface = + cairo_svg_surface_create_for_stream(&dummy_writer, nullptr, iWidth, iHeight); + cairo_t * cc = cairo_create(surface); + + JsonPainter painter(out, iDoc->cascade(), iFonts.get(), cc); + + const auto viewMap = page->viewMap(view, iDoc->cascade()); + painter.setAttributeMap(&viewMap); + std::vector layerMatrices = page->layerMatrices(view); + painter.pushMatrix(); + + Attribute bg = page->backgroundSymbol(iDoc->cascade()); + const Symbol * background = iDoc->cascade()->findSymbol(bg); + if (background && page->findLayer("BACKGROUND") < 0) painter.drawSymbol(bg); + + const Text * title = page->titleText(variant); + if (title) { title->draw(painter); } + + for (int i = 0; i < page->count(); ++i) { + if (page->objectVisible(view, i)) { + auto obj = page->object(i); + if (!obj->displayInVariant(variant)) continue; + if (obj->type() == Object::EText) {} + painter.pushMatrix(); + painter.transform(layerMatrices[page->layerOf(i)]); + obj->draw(painter); + painter.popMatrix(); + } + } + painter.popMatrix(); + out << "]\n"; // objects + out << "}"; // page + cairo_destroy(cc); + cairo_surface_destroy(surface); +} + +bool Reveal::createJson(const char * destination, bool onlyMarked) { + std::FILE * file = Platform::fopen(destination, "wb"); + if (!file) return false; + FileStream out(file); + + out << "{\n"; + if (!iDoc->properties().iTitle.empty()) { + out << "\"title\": \""; + out.putJsonString(iDoc->properties().iTitle); + out << "\",\n"; + } + if (!iDoc->properties().iAuthor.empty()) { + out << "\"author\": \""; + out.putJsonString(iDoc->properties().iAuthor); + out << "\",\n"; + } + if (!iDoc->properties().iKeywords.empty()) { + out << "\"keywords\": \""; + out.putJsonString(iDoc->properties().iKeywords); + out << "\",\n"; + } + if (!iDoc->properties().iSubject.empty()) { + out << "\"subject\": \""; + out.putJsonString(iDoc->properties().iSubject); + out << "\",\n"; + } + out << "\"created\": \"" << iDoc->properties().iCreated << "\",\n"; + out << "\"modified\": \"" << iDoc->properties().iModified << "\",\n"; + Vector offset = iLayout->paper().topLeft(); + + out << "\"width\": " << iWidth << ",\n"; + out << "\"height\": " << iHeight << ",\n"; + out << "\"originX\": " << -offset.x << ",\n"; + out << "\"originY\": " << offset.y << ",\n"; + + out << "\"pages\": [\n"; + + bool sep = false; + + for (int pageNo = iFromPage; pageNo <= iToPage; ++pageNo) { + const Page * page = iDoc->page(pageNo); + int nViews = page->countViews(); + if (onlyMarked) { + if (!page->marked()) continue; + bool shown = false; + for (int view = 0; view < nViews; ++view) { + if (page->markedView(view)) { + createJsonView(out, pageNo, view, sep); + shown = true; + } + } + if (!shown) createJsonView(out, pageNo, nViews - 1, sep); + } else { + for (int view = 0; view < nViews; ++view) + createJsonView(out, pageNo, view, sep); + } + } + out << "]\n"; // pages + out << "}\n"; // document + out.close(); + fclose(file); + return true; +} + +// -------------------------------------------------------------------- diff --git a/src/ipelib/ipebase.cpp b/src/ipelib/ipebase.cpp index 03787b6..a01f848 100644 --- a/src/ipelib/ipebase.cpp +++ b/src/ipelib/ipebase.cpp @@ -690,6 +690,30 @@ void Stream::putXmlString(String s) { } } +//! Save a string with Json escaping +void Stream::putJsonString(String s) { + for (int i = 0; i < s.size(); ++i) { + char ch = s[i]; + switch (ch) { + case '"': *this << "\\\""; break; + case '\\': *this << "\\\\"; break; + case '\b': *this << "\\b"; break; + case '\f': *this << "\\f"; break; + case '\n': *this << "\\n"; break; + case '\r': *this << "\\r"; break; + case '\t': *this << "\\t"; break; + default: + if (0 <= ch && ch < 0x20) { + *this << "\\u00"; + putHexByte(ch); + *this << "x"; + } else { + *this << ch; // UTF-8 multibyte sequences pass through unchanged + } + } + } +} + // -------------------------------------------------------------------- /*! \class ipe::StringStream diff --git a/src/ipelib/ipeutils.cpp b/src/ipelib/ipeutils.cpp index 46cb5eb..ddf0399 100644 --- a/src/ipelib/ipeutils.cpp +++ b/src/ipelib/ipeutils.cpp @@ -231,8 +231,9 @@ inline void base64encode(uint32_t w, char * p) { p[0] = base64letter[w % 64]; } -Base64Stream::Base64Stream(Stream & stream) - : iStream(stream) { +Base64Stream::Base64Stream(Stream & stream, bool noLineBreaks) + : iStream(stream) + , iNoLineBreaks{noLineBreaks} { iN = 0; iCol = 0; } @@ -247,7 +248,7 @@ void Base64Stream::putChar(char ch) { iStream.putCString(buf); iCol += 4; if (iCol > 70) { - iStream.putChar('\n'); + if (!iNoLineBreaks) iStream.putChar('\n'); iCol = 0; } iN = 0; @@ -263,7 +264,7 @@ void Base64Stream::close() { for (int k = iN + 1; k < 4; ++k) buf[k] = '='; iStream.putCString(buf); } - iStream.putCString("\n"); + if (!iNoLineBreaks) iStream.putCString("\n"); iStream.close(); } diff --git a/src/iperender/iperender.cpp b/src/iperender/iperender.cpp index edb0dad..8187e1b 100644 --- a/src/iperender/iperender.cpp +++ b/src/iperender/iperender.cpp @@ -82,7 +82,8 @@ static int renderPage(Thumbnail::TargetFormat fm, const char * src, const char * // -------------------------------------------------------------------- -static int renderReveal(const char * src, const char * dst, const char * pageSpec) { +static int renderReveal(const char * src, const char * dst, const char * pageSpec, + bool toJson) { Document * doc = Document::loadWithErrorReport(src); if (!doc) return 1; @@ -105,11 +106,16 @@ static int renderReveal(const char * src, const char * dst, const char * pageSpe } ipe::String htmlTemplate = "[TITLE]" - "[IPE]"; + "[IPE]"; Reveal reveal(doc, fromPage, toPage); - if (!reveal.createPresentation(dst, htmlTemplate)) - fprintf(stderr, "Failure to create presentation.\n"); + if (toJson) { + if (!reveal.createJson(dst, true)) + fprintf(stderr, "Failure to create json file.\n"); + } else { + if (!reveal.createPresentation(dst, htmlTemplate)) + fprintf(stderr, "Failure to create presentation.\n"); + } delete doc; return 0; @@ -127,7 +133,9 @@ static void usage() { #endif #ifdef CAIRO_HAS_SVG_SURFACE fprintf(stderr, "| -svg "); + fprintf(stderr, "| -html "); #endif + fprintf(stderr, "| -json "); fprintf(stderr, "] " "[ -page ] [ -view ] [ -resolution ] " "[ -transparent ] [ -nocrop ] " @@ -150,6 +158,7 @@ int main(int argc, char * argv[]) { if (argc < 4) usage(); bool renderHtml = false; + bool renderJson = false; Thumbnail::TargetFormat fm = Thumbnail::EPNG; if (!strcmp(argv[1], "-png")) fm = Thumbnail::EPNG; #ifdef CAIRO_HAS_PS_SURFACE @@ -166,6 +175,8 @@ int main(int argc, char * argv[]) { else if (!strcmp(argv[1], "-svg")) fm = Thumbnail::ESVG; #endif + else if (!strcmp(argv[1], "-json")) + renderJson = true; else usage(); @@ -208,8 +219,8 @@ int main(int argc, char * argv[]) { const char * src = argv[i]; const char * dst = argv[i + 1]; - if (renderHtml) - return renderReveal(src, dst, page); + if (renderHtml || renderJson) + return renderReveal(src, dst, page, renderJson); else return renderPage(fm, src, dst, page, view, dpi / 72.0, tolerance, transparent, nocrop);