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 @@
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]*?(${tag}>)`),
+ `$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]