@richscripts/richtexteditor 2.15.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +9 -0
  2. package/angular/build-manifest.json +4 -4
  3. package/angular/index.mjs +1 -1
  4. package/index.d.ts +42 -33
  5. package/integrations/shared.js +10 -0
  6. package/package.json +1 -1
  7. package/richtexteditor/all_plugins.js +2062 -1542
  8. package/richtexteditor/all_plugins.min.js +76 -61
  9. package/richtexteditor/lang/rte-lang-de.js +33 -0
  10. package/richtexteditor/lang/rte-lang-es.js +33 -0
  11. package/richtexteditor/lang/rte-lang-fr.js +33 -0
  12. package/richtexteditor/lang/rte-lang-it.js +33 -0
  13. package/richtexteditor/lang/rte-lang-ja.js +33 -0
  14. package/richtexteditor/lang/rte-lang-ko.js +33 -0
  15. package/richtexteditor/lang/rte-lang-nl.js +33 -0
  16. package/richtexteditor/lang/rte-lang-pt-pt.js +33 -0
  17. package/richtexteditor/lang/rte-lang-pt.js +33 -0
  18. package/richtexteditor/lang/rte-lang-ru.js +33 -0
  19. package/richtexteditor/lang/rte-lang-zh-Hans.js +33 -0
  20. package/richtexteditor/lang/rte-lang-zh-Hant.js +33 -0
  21. package/richtexteditor/plugins/a11yenhance.js +3 -1
  22. package/richtexteditor/plugins/a11yenhance.min.js +1 -1
  23. package/richtexteditor/plugins/accessibilitychecker.js +7 -7
  24. package/richtexteditor/plugins/accessibilitychecker.min.js +1 -1
  25. package/richtexteditor/plugins/aitoolkit.js +91 -83
  26. package/richtexteditor/plugins/aitoolkit.min.js +4 -4
  27. package/richtexteditor/plugins/all_plugins.js +2062 -1542
  28. package/richtexteditor/plugins/all_plugins.min.js +76 -61
  29. package/richtexteditor/plugins/autoembed.js +1 -1
  30. package/richtexteditor/plugins/autoembed.min.js +1 -1
  31. package/richtexteditor/plugins/charlimit.js +2 -2
  32. package/richtexteditor/plugins/charlimit.min.js +2 -2
  33. package/richtexteditor/plugins/comments.js +2 -2
  34. package/richtexteditor/plugins/comments.min.js +2 -2
  35. package/richtexteditor/plugins/contentminimap.js +3 -3
  36. package/richtexteditor/plugins/contentminimap.min.js +1 -1
  37. package/richtexteditor/plugins/crdt-engine.js +27 -1
  38. package/richtexteditor/plugins/documentimport.js +30 -8
  39. package/richtexteditor/plugins/documentimport.min.js +4 -4
  40. package/richtexteditor/plugins/docxexport.js +30 -1
  41. package/richtexteditor/plugins/docxexport.min.js +2 -2
  42. package/richtexteditor/plugins/emojiautocomplete.js +1 -1
  43. package/richtexteditor/plugins/emojiautocomplete.min.js +1 -1
  44. package/richtexteditor/plugins/html2pdf.js +7 -7
  45. package/richtexteditor/plugins/html2pdf.min.js +1 -1
  46. package/richtexteditor/plugins/imageeditor.js +19 -6
  47. package/richtexteditor/plugins/imageeditor.min.js +1 -1
  48. package/richtexteditor/plugins/insertcode.js +8 -8
  49. package/richtexteditor/plugins/insertcode.min.js +4 -4
  50. package/richtexteditor/plugins/insertemoji.js +4 -4
  51. package/richtexteditor/plugins/insertemoji.min.js +1 -1
  52. package/richtexteditor/plugins/insertgallery.js +4 -4
  53. package/richtexteditor/plugins/insertgallery.min.js +1 -1
  54. package/richtexteditor/plugins/inserttemplate.js +10 -2
  55. package/richtexteditor/plugins/inserttemplate.min.js +1 -1
  56. package/richtexteditor/plugins/markdownexport.js +1 -1
  57. package/richtexteditor/plugins/markdownexport.min.js +2 -2
  58. package/richtexteditor/plugins/mentionplugin.js +2 -2
  59. package/richtexteditor/plugins/mentionplugin.min.js +1 -1
  60. package/richtexteditor/plugins/mermaiddiagram.js +5 -5
  61. package/richtexteditor/plugins/mermaiddiagram.min.js +2 -2
  62. package/richtexteditor/plugins/nodetypes.js +1 -1
  63. package/richtexteditor/plugins/nodetypes.min.js +1 -1
  64. package/richtexteditor/plugins/pagination.js +2 -2
  65. package/richtexteditor/plugins/pagination.min.js +1 -1
  66. package/richtexteditor/plugins/pdfexport.js +1751 -1366
  67. package/richtexteditor/plugins/pdfexport.min.js +28 -13
  68. package/richtexteditor/plugins/readaloud.js +1 -1
  69. package/richtexteditor/plugins/readaloud.min.js +1 -1
  70. package/richtexteditor/plugins/revisionhistory.js +4 -4
  71. package/richtexteditor/plugins/revisionhistory.min.js +2 -2
  72. package/richtexteditor/plugins/rubyannotation.js +2 -2
  73. package/richtexteditor/plugins/rubyannotation.min.js +1 -1
  74. package/richtexteditor/plugins/sanitizer.js +57 -4
  75. package/richtexteditor/plugins/sanitizer.min.js +1 -1
  76. package/richtexteditor/plugins/slashcommand.js +2 -2
  77. package/richtexteditor/plugins/slashcommand.min.js +2 -2
  78. package/richtexteditor/plugins/smartchips.js +3 -3
  79. package/richtexteditor/plugins/smartchips.min.js +1 -1
  80. package/richtexteditor/plugins/spellcheck.js +3 -3
  81. package/richtexteditor/plugins/spellcheck.min.js +1 -1
  82. package/richtexteditor/plugins/stateinspector.js +2 -2
  83. package/richtexteditor/plugins/stateinspector.min.js +2 -2
  84. package/richtexteditor/plugins/structured-content-bridge.js +34 -8
  85. package/richtexteditor/plugins/structured-content-bridge.min.js +5 -5
  86. package/richtexteditor/plugins/textdirection.js +1 -1
  87. package/richtexteditor/plugins/textdirection.min.js +1 -1
  88. package/richtexteditor/plugins/trackedchanges.js +1 -1
  89. package/richtexteditor/plugins/trackedchanges.min.js +1 -1
  90. package/richtexteditor/plugins/yjscollab.js +2 -2
  91. package/richtexteditor/plugins/yjscollab.min.js +1 -1
  92. package/richtexteditor/rte-config.js +14 -36
  93. package/richtexteditor/rte-watchdog.js +3 -1
  94. package/richtexteditor/rte-webcomponent.js +3 -1
  95. package/richtexteditor/rte.js +16 -38
  96. package/richtexteditor/runtime/colorpicker_more_ns.htm +3 -1
@@ -1,1367 +1,1752 @@
1
- if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
2
-
3
- // 2026-08-02 PDF export that produces REAL TEXT.
4
- //
5
- // The existing html2pdf path renders the document with html2canvas and drops a
6
- // JPEG onto each page. It is pixel-accurate, which is genuinely the right answer
7
- // when the layout is the point — but the resulting PDF is a picture:
8
- //
9
- // - nothing is selectable, copyable or searchable
10
- // - screen readers find an empty document (a hard accessibility failure, and
11
- // an outright blocker for anyone shipping into government or education)
12
- // - links are painted, not clickable
13
- // - a text-only page costs hundreds of KB instead of a few
14
- // - zooming or printing shows resampling artefacts
15
- //
16
- // This writes the PDF file format directly: no library, no canvas, no network.
17
- // Both exporters stay available and neither changes the other's behaviour —
18
- // `exportpdf` is vector, `html2pdf` remains raster.
19
- //
20
- // Why writing PDF by hand is reasonable here: a text PDF is a small object graph
21
- // plus a content stream, and the 14 standard fonts need no embedding at all. The
22
- // hard part of a PDF generator is font subsetting, and using the base-14 fonts
23
- // removes it entirely.
24
- //
25
- // API:
26
- // editor.getPdfBytes(options) -> Uint8Array
27
- // editor.exportToPdf(filename, options)-> triggers a download
28
- // Command: exec_command "exportpdf".
29
- // Config:
30
- // config.pdfExport = false // disable
31
- // config.pdfExportFileName = "report"
32
- // config.pdfExportFont = "helvetica" // helvetica | times | courier
33
- // config.pdfExportBaseFontSize = 11 // points
34
- // config.pdfExportTitle / pdfExportAuthor // document metadata
35
- RTE_DefaultConfig.plugin_pdfexport = RTE_Plugin_PdfExport;
36
- if (typeof RTE_DefaultConfig.pdfExport === "undefined") RTE_DefaultConfig.pdfExport = true;
37
-
38
- function RTE_Plugin_PdfExport() {
39
- // An ordered list item's number in its list style: 1, a..z then aa (CSS lower-alpha), i/I roman.
40
- function listNumber(n, kind) {
41
- if (kind === "a" || kind === "A") {
42
- var t = "";
43
- for (var k = n; k > 0; k = Math.floor((k - 1) / 26)) t = String.fromCharCode(97 + (k - 1) % 26) + t;
44
- return t ? (kind === "A" ? t.toUpperCase() : t) : String(n); // 0 and below: a number, never ""
45
- }
46
- if ((kind === "i" || kind === "I") && n > 0 && n < 4000) {
47
- var r = "", vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1], sy = ["m", "cm", "d", "cd", "c", "xc", "l", "xl", "x", "ix", "v", "iv", "i"];
48
- for (var q = 0; q < vals.length; q++) while (n >= vals[q]) { r += sy[q]; n -= vals[q]; }
49
- return kind === "I" ? r.toUpperCase() : r;
50
- }
51
- return String(n);
52
- }
53
-
54
- var obj = this;
55
- var config, editor;
56
-
57
- obj.PluginName = "PdfExport";
58
-
59
- obj.InitConfig = function (argconfig) { config = argconfig; };
60
-
61
- obj.InitEditor = function (argeditor) {
62
- editor = argeditor;
63
- if (config.pdfExport === false) return;
64
-
65
- editor.getPdfBytes = function (options) { return buildPdf(options || {}); };
66
- editor.exportToPdf = function (filename, options) {
67
- var bytes = buildPdf(options || {});
68
- var base = sanitizeName(filename) || sanitizeName(config.pdfExportFileName) || defaultBase();
69
- return download(bytes, base + ".pdf");
70
- };
71
- editor.downloadPdf = editor.exportToPdf;
72
-
73
- editor.attachEvent("exec_command_exportpdf", function (state) {
74
- state.returnValue = true;
75
- state.stopBubble = true;
76
- try { editor.exportToPdf(); }
77
- catch (e) { if (window.console) console.error("pdfexport:", e); }
78
- });
79
-
80
- // The slash-menu entry lives in slashcommand.js, gated on
81
- // `typeof editor.exportToPdf === "function"`. Registering from here
82
- // instead would silently do nothing: plugins initialise in bundle
83
- // order, "pdfexport" sorts before "slashcommand", and
84
- // editor.slashCommands does not exist yet at this point.
85
- };
86
-
87
- // ---------------------------------------------------------------- units
88
- var PT_PER_IN = 72;
89
- var PAGE_SIZES = { // points, portrait
90
- letter: [612, 792], legal: [612, 1008], tabloid: [792, 1224],
91
- a3: [842, 1191], a4: [595, 842], a5: [420, 595]
92
- };
93
-
94
- function toPoints(v, fallback) {
95
- if (typeof v === "number" && isFinite(v)) return v * PT_PER_IN; // bare number = inches
96
- if (typeof v !== "string") return fallback;
97
- var m = /^\s*(-?[\d.]+)\s*(mm|cm|in|px|pt)?\s*$/.exec(v);
98
- if (!m) return fallback;
99
- var n = parseFloat(m[1]);
100
- if (!isFinite(n)) return fallback;
101
- switch (m[2]) {
102
- case "mm": return n * PT_PER_IN / 25.4;
103
- case "cm": return n * PT_PER_IN / 2.54;
104
- case "pt": return n;
105
- case "px": return n * 0.75; // CSS px at 96dpi
106
- default: return n * PT_PER_IN;
107
- }
108
- }
109
-
110
- function pageGeometry(options) {
111
- var setup = null;
112
- try { if (typeof editor.getDocumentPageSetup === "function") setup = editor.getDocumentPageSetup(); }
113
- catch (e) { setup = null; }
114
- setup = setup || {};
115
- var fmt = String(options.format || setup.format || "letter").toLowerCase();
116
- var size = PAGE_SIZES[fmt] || PAGE_SIZES.letter;
117
- var landscape = String(options.orientation || setup.orientation || "").toLowerCase() === "landscape";
118
- var w = landscape ? size[1] : size[0];
119
- var h = landscape ? size[0] : size[1];
120
- var m = options.margins || setup.margins || {};
121
- var half = PT_PER_IN / 2;
122
- return {
123
- width: w, height: h,
124
- margin: {
125
- top: toPoints(m.top, half), right: toPoints(m.right, half),
126
- bottom: toPoints(m.bottom, half), left: toPoints(m.left, half)
127
- }
128
- };
129
- }
130
-
131
- // ---------------------------------------------------------------- fonts
132
- //
133
- // The base-14 fonts need no embedding, but a viewer would then lay text out
134
- // with ITS metrics while this code wrapped lines using its own. The two
135
- // disagree and text overruns the margin. Fixing it by shipping AFM width
136
- // tables would add ~1500 lines of data; instead the widths are measured here
137
- // and written into the font object as /Widths, which a viewer is required to
138
- // honour. Layout and rendering then use the same numbers by construction.
139
- //
140
- // Measurement is accurate because the base-14 fonts are metric-compatible
141
- // with fonts every browser has: Helvetica/Arial, Times/Times New Roman,
142
- // Courier/Courier New were designed to share advance widths.
143
- var FONT_FAMILIES = {
144
- helvetica: { css: "Arial, Helvetica, sans-serif", faces: ["Helvetica", "Helvetica-Bold", "Helvetica-Oblique", "Helvetica-BoldOblique"] },
145
- times: { css: '"Times New Roman", Times, serif', faces: ["Times-Roman", "Times-Bold", "Times-Italic", "Times-BoldItalic"] },
146
- courier: { css: '"Courier New", Courier, monospace', faces: ["Courier", "Courier-Bold", "Courier-Oblique", "Courier-BoldOblique"] }
147
- };
148
- var FIRST_CHAR = 32, LAST_CHAR = 255;
149
-
150
- var widthCache = {};
151
- function widthsFor(familyKey, styleIndex) {
152
- var key = familyKey + ":" + styleIndex;
153
- if (widthCache[key]) return widthCache[key];
154
- var fam = FONT_FAMILIES[familyKey] || FONT_FAMILIES.helvetica;
155
- var canvas = widthsFor.canvas || (widthsFor.canvas = document.createElement("canvas"));
156
- var g = canvas.getContext("2d");
157
- // 1000 units per em is the PDF glyph space; measuring at 100px and
158
- // scaling by 10 keeps sub-unit rounding away from the result.
159
- var weight = (styleIndex & 1) ? "bold " : "";
160
- var slant = (styleIndex & 2) ? "italic " : "";
161
- g.font = weight + slant + "100px " + fam.css;
162
- var out = [];
163
- for (var c = FIRST_CHAR; c <= LAST_CHAR; c++) {
164
- var w = g.measureText(String.fromCharCode(c)).width;
165
- out.push(Math.round(w * 10));
166
- }
167
- widthCache[key] = out;
168
- return out;
169
- }
170
-
171
- function charWidth(familyKey, styleIndex, code) {
172
- if (code < FIRST_CHAR || code > LAST_CHAR) code = 63; // '?'
173
- return widthsFor(familyKey, styleIndex)[code - FIRST_CHAR] || 500;
174
- }
175
-
176
- function textWidth(str, familyKey, styleIndex, size) {
177
- var total = 0;
178
- for (var i = 0; i < str.length; i++) total += charWidth(familyKey, styleIndex, str.charCodeAt(i));
179
- return total * size / 1000;
180
- }
181
-
182
- // ------------------------------------------------------------ PDF string
183
- //
184
- // WinAnsiEncoding covers Latin-1 plus the printable range of CP1252. A
185
- // character outside it has no glyph in a base-14 font, so it is transliterated
186
- // rather than written as a byte the viewer would render as garbage.
187
- var WINANSI_EXTRA = {
188
- 0x20AC: 128, 0x201A: 130, 0x0192: 131, 0x201E: 132, 0x2026: 133, 0x2020: 134,
189
- 0x2021: 135, 0x02C6: 136, 0x2030: 137, 0x0160: 138, 0x2039: 139, 0x0152: 140,
190
- 0x017D: 142, 0x2018: 145, 0x2019: 146, 0x201C: 147, 0x201D: 148, 0x2022: 149,
191
- 0x2013: 150, 0x2014: 151, 0x02DC: 152, 0x2122: 153, 0x0161: 154, 0x203A: 155,
192
- 0x0153: 156, 0x017E: 158, 0x0178: 159
193
- };
194
- function toWinAnsi(str) {
195
- var out = "";
196
- for (var i = 0; i < str.length; i++) {
197
- var cp = str.charCodeAt(i);
198
- if (cp < 256) { out += String.fromCharCode(cp); continue; }
199
- var mapped = WINANSI_EXTRA[cp];
200
- if (mapped) { out += String.fromCharCode(mapped); continue; }
201
- // Common shapes that would otherwise become "?" for no good reason.
202
- if (cp === 0x00A0) { out += " "; continue; }
203
- if (cp >= 0x2000 && cp <= 0x200A) { out += " "; continue; }
204
- if (cp === 0x2212) { out += "-"; continue; }
205
- out += "?";
206
- }
207
- return out;
208
- }
209
- function escapeString(str) {
210
- return str.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
211
- }
212
-
213
- // ------------------------------------------------------------- PDF writer
214
- function PdfWriter() {
215
- this.objects = []; // 1-based; objects[i] is the body of object i+1
216
- }
217
- PdfWriter.prototype.alloc = function () { this.objects.push(null); return this.objects.length; };
218
- PdfWriter.prototype.set = function (id, body) { this.objects[id - 1] = body; };
219
- PdfWriter.prototype.add = function (body) { var id = this.alloc(); this.set(id, body); return id; };
220
-
221
- // Assembled as BYTES, not as a string: a content stream can hold binary
222
- // (an embedded JPEG), and /Length plus the xref offsets are byte counts. A
223
- // string-based assembler is correct right up until the first image, then
224
- // silently produces a file no reader will open.
225
- PdfWriter.prototype.build = function (rootId, infoId) {
226
- var chunks = [], length = 0;
227
- function push(part) {
228
- var bytes = (typeof part === "string") ? latin1Bytes(part) : part;
229
- chunks.push(bytes); length += bytes.length;
230
- return length;
231
- }
232
- push("%PDF-1.4\n");
233
- // A binary comment marks the file as binary for transfer agents.
234
- push(new Uint8Array([0x25, 0xE2, 0xE3, 0xCF, 0xD3, 0x0A]));
235
-
236
- var offsets = [];
237
- for (var i = 0; i < this.objects.length; i++) {
238
- offsets[i] = length;
239
- push((i + 1) + " 0 obj\n");
240
- var body = this.objects[i];
241
- if (body && body.stream) {
242
- var data = (typeof body.stream === "string") ? latin1Bytes(body.stream) : body.stream;
243
- push(body.dict.replace("/Length 0", "/Length " + data.length) + "\nstream\n");
244
- push(data);
245
- push("\nendstream\n");
246
- } else {
247
- push(body + "\n");
248
- }
249
- push("endobj\n");
250
- }
251
- var xrefAt = length;
252
- var xref = "xref\n0 " + (this.objects.length + 1) + "\n0000000000 65535 f \n";
253
- for (var j = 0; j < offsets.length; j++) {
254
- xref += pad10(offsets[j]) + " 00000 n \n";
255
- }
256
- push(xref);
257
- push("trailer\n<< /Size " + (this.objects.length + 1) + " /Root " + rootId + " 0 R /Info " + infoId + " 0 R >>\n");
258
- push("startxref\n" + xrefAt + "\n%%EOF\n");
259
-
260
- var out = new Uint8Array(length), at = 0;
261
- for (var k = 0; k < chunks.length; k++) { out.set(chunks[k], at); at += chunks[k].length; }
262
- return out;
263
- };
264
- function pad10(n) { var s = String(n); while (s.length < 10) s = "0" + s; return s; }
265
- function latin1Bytes(str) {
266
- var out = new Uint8Array(str.length);
267
- for (var i = 0; i < str.length; i++) out[i] = str.charCodeAt(i) & 0xFF;
268
- return out;
269
- }
270
-
271
- // ---------------------------------------------------------------- layout
272
- //
273
- // A flat list of positioned draw operations is produced first, then paged.
274
- // Keeping measurement and pagination apart is what makes "keep this heading
275
- // with the paragraph under it" and "repeat a table header row" possible at
276
- // all; doing both in one pass forces every decision to be final immediately.
277
-
278
- function styleOf(node, inherited) {
279
- var s = {
280
- bold: inherited.bold, italic: inherited.italic, underline: inherited.underline,
281
- strike: inherited.strike, size: inherited.size, color: inherited.color,
282
- mono: inherited.mono, link: inherited.link, lang: inherited.lang
283
- };
284
- // 2026-10-17 a run's language: the nearest lang attribute. tools/pdf/inline-lang.mjs
285
- var nodeLang = node.getAttribute ? node.getAttribute("lang") : "";
286
- if (nodeLang) s.lang = nodeLang;
287
- var tag = node.tagName ? node.tagName.toLowerCase() : "";
288
- if (tag === "b" || tag === "strong") s.bold = true;
289
- if (tag === "i" || tag === "em") s.italic = true;
290
- if (tag === "u" || tag === "ins") s.underline = true;
291
- if (tag === "s" || tag === "strike" || tag === "del") s.strike = true;
292
- if (tag === "code" || tag === "kbd" || tag === "samp" || tag === "pre") s.mono = true;
293
- if (tag === "a" && node.getAttribute("href")) { s.link = node.getAttribute("href"); s.color = [0, 0.33, 0.8]; s.underline = true; }
294
- if (tag === "sup" || tag === "sub") s.size = s.size * 0.75;
295
-
296
- var css = node.style;
297
- if (css) {
298
- if (/bold|^[6-9]00$/.test(css.fontWeight || "")) s.bold = true;
299
- if (css.fontStyle === "italic" || css.fontStyle === "oblique") s.italic = true;
300
- if ((css.textDecorationLine || css.textDecoration || "").indexOf("underline") >= 0) s.underline = true;
301
- if ((css.textDecorationLine || css.textDecoration || "").indexOf("line-through") >= 0) s.strike = true;
302
- if (css.color) { var c = parseColor(css.color); if (c) s.color = c; }
303
- if (css.fontSize) { var fs = toPoints(css.fontSize, null); if (fs) s.size = fs; }
304
- }
305
- return s;
306
- }
307
-
308
- function parseColor(value) {
309
- var m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.replace(/\s/g, ""));
310
- if (m) {
311
- var hex = m[1];
312
- if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
313
- return [parseInt(hex.substr(0, 2), 16) / 255, parseInt(hex.substr(2, 2), 16) / 255, parseInt(hex.substr(4, 2), 16) / 255];
314
- }
315
- var rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(value);
316
- if (rgb) return [rgb[1] / 255, rgb[2] / 255, rgb[3] / 255];
317
- return null;
318
- }
319
-
320
- var HEADING_SCALE = { h1: 2, h2: 1.6, h3: 1.35, h4: 1.15, h5: 1, h6: 0.9 };
321
-
322
- function Layout(geom, baseSize, familyKey) {
323
- this.geom = geom;
324
- this.baseSize = baseSize;
325
- this.family = familyKey;
326
- this.contentWidth = geom.width - geom.margin.left - geom.margin.right;
327
- this.blocks = []; // { type, ... } in document order
328
- }
329
-
330
- // Collect the inline runs of a block, splitting into words for wrapping.
331
- Layout.prototype.runsOf = function (node, inherited) {
332
- var runs = [];
333
- var self = this;
334
- (function walk(n, style) {
335
- for (var i = 0; i < n.childNodes.length; i++) {
336
- var c = n.childNodes[i];
337
- if (c.nodeType === 3) {
338
- var text = (c.nodeValue || "").replace(/\s+/g, " ");
339
- if (text) runs.push({ text: text, style: style });
340
- continue;
341
- }
342
- if (c.nodeType !== 1) continue;
343
- var tag = c.tagName.toLowerCase();
344
- if (tag === "br") { runs.push({ br: true, style: style }); continue; }
345
- if (tag === "img") { runs.push({ img: c, style: style }); continue; }
346
- if (tag === "script" || tag === "style") continue;
347
- walk(c, styleOf(c, style));
348
- }
349
- })(node, inherited);
350
- return runs;
351
- };
352
-
353
- // Break runs into laid-out lines that fit `width`.
354
- Layout.prototype.wrap = function (runs, width, lineHeightFactor) {
355
- var lines = [], line = [], used = 0, self = this;
356
- var maxSize = this.baseSize;
357
-
358
- function flush() {
359
- // Trailing spaces must not count toward a line's width, or a
360
- // right-aligned or centred line drifts by the width of the space.
361
- while (line.length && /^\s+$/.test(line[line.length - 1].text || "")) line.pop();
362
- lines.push({ parts: line, width: used, size: maxSize });
363
- line = []; used = 0; maxSize = self.baseSize;
364
- }
365
-
366
- for (var r = 0; r < runs.length; r++) {
367
- var run = runs[r];
368
- if (run.br) { flush(); continue; }
369
- if (run.img) {
370
- var dims = imageDims(run.img, width);
371
- if (line.length) flush();
372
- lines.push({ parts: [{ img: run.img, w: dims.w, h: dims.h, style: run.style }], width: dims.w, size: dims.h, image: true });
373
- continue;
374
- }
375
- var styleIndex = (run.style.bold ? 1 : 0) | (run.style.italic ? 2 : 0);
376
- var family = run.style.mono ? "courier" : this.family;
377
- // Split keeping the separators, so a space that ends a line can be
378
- // dropped while a space between two words on the same line is kept.
379
- var tokens = run.text.split(/(\s+)/);
380
- for (var t = 0; t < tokens.length; t++) {
381
- var tok = tokens[t];
382
- if (!tok) continue;
383
- var w = textWidth(toWinAnsi(tok), family, styleIndex, run.style.size);
384
- if (used + w > width && line.length && !/^\s+$/.test(tok)) {
385
- flush();
386
- // A wrapped line never starts with the space that caused it.
387
- if (/^\s+$/.test(tok)) continue;
388
- }
389
- line.push({ text: tok, style: run.style, family: family, styleIndex: styleIndex, w: w });
390
- used += w;
391
- if (run.style.size > maxSize) maxSize = run.style.size;
392
- }
393
- }
394
- if (line.length) flush();
395
- if (!lines.length) lines.push({ parts: [], width: 0, size: this.baseSize });
396
- for (var i = 0; i < lines.length; i++) lines[i].height = lines[i].image ? lines[i].size : lines[i].size * lineHeightFactor;
397
- return lines;
398
- };
399
-
400
- function imageDims(img, maxWidth) {
401
- var w = img.naturalWidth || img.width || 0;
402
- var h = img.naturalHeight || img.height || 0;
403
- if (!w || !h) return { w: 0, h: 0 };
404
- var pw = w * 0.75, ph = h * 0.75; // CSS px -> points
405
- if (pw > maxWidth) { ph = ph * maxWidth / pw; pw = maxWidth; }
406
- return { w: pw, h: ph };
407
- }
408
-
409
- // ------------------------------------------------------------ block walk
410
- Layout.prototype.run = function (root) {
411
- var self = this;
412
- var base = { bold: false, italic: false, underline: false, strike: false, size: this.baseSize, color: [0, 0, 0], mono: false, link: null };
413
-
414
- // A block indented in the editor carries margin-left (or padding-left, per
415
- // indentUseMargin). Before 2026-09-04 the PDF honoured an indent only when
416
- // it was spelled <blockquote>, because that was the only way the editor
417
- // could express one. Points, since that is the PDF's unit: 1px = 0.75pt.
418
- function cssIndentPt(node) {
419
- if (!node || !node.style) return 0;
420
- var raw = node.style.marginLeft || node.style.paddingLeft || "";
421
- var m = /^\s*(-?[\d.]+)\s*([a-z%]*)\s*$/i.exec(raw);
422
- if (!m) return 0;
423
- var n = parseFloat(m[1]);
424
- if (!n || n <= 0) return 0;
425
- var unit = (m[2] || "px").toLowerCase();
426
- var px = unit === "px" ? n
427
- : unit === "pt" ? n * (4 / 3)
428
- : unit === "in" ? n * 96
429
- : unit === "cm" ? n * 37.795
430
- : unit === "mm" ? n * 3.7795
431
- : (unit === "em" || unit === "rem") ? n * 16
432
- : 0;
433
- return px * 0.75;
434
- }
435
-
436
- // The language a block is written in: its own lang, the nearest ancestor's inside the content, or - when its
437
- // whole text is one <span lang> - that span's. "" when none is set.
438
- function blockLang(node) {
439
- for (var n = node; n && n.nodeType === 1 && n.tagName.toLowerCase() !== "body"; n = n.parentNode) {
440
- if (n.getAttribute("lang")) return n.getAttribute("lang");
441
- }
442
- var el = null, k = node.childNodes;
443
- for (var i = 0; i < k.length; i++) {
444
- if (k[i].nodeType === 3 && k[i].nodeValue.replace(/[\s\u200b\u00a0]/g, "")) return "";
445
- if (k[i].nodeType === 1) { if (el) return ""; el = k[i]; }
446
- }
447
- return el && el.getAttribute("lang") ? el.getAttribute("lang") : "";
448
- }
449
- function block(node, style, indent, listMarker, listId, listOrdered, roleOverride) {
450
- var tag = node.tagName.toLowerCase();
451
- var s = styleOf(node, style);
452
- var align = "left";
453
- var ta = (node.style && node.style.textAlign) || "";
454
- if (ta === "center" || ta === "right" || ta === "justify") align = ta;
455
-
456
- if (HEADING_SCALE[tag]) { s.size = self.baseSize * HEADING_SCALE[tag]; s.bold = true; }
457
- if (tag === "pre") s.mono = true;
458
-
459
- var width = self.contentWidth - indent;
460
- var runs = self.runsOf(node, s);
461
- var lines = self.wrap(runs, width, tag === "pre" ? 1.25 : 1.4);
462
- self.blocks.push({
463
- type: "text", lines: lines, indent: indent, align: align,
464
- // 2026-10-15 the block's own language, for /Lang on its structure element. tools/pdf/paragraph-lang.mjs
465
- lang: blockLang(node),
466
- spaceBefore: HEADING_SCALE[tag] ? s.size * 0.6 : self.baseSize * 0.35,
467
- spaceAfter: HEADING_SCALE[tag] ? s.size * 0.3 : self.baseSize * 0.35,
468
- marker: listMarker || null, markerStyle: s,
469
- // A heading alone at the foot of a page is the classic ugly
470
- // break; it is pinned to what follows it.
471
- keepWithNext: !!HEADING_SCALE[tag],
472
- rule: tag === "blockquote" ? "quote" : null,
473
- // Semantics for the tag tree. A PDF whose text is selectable but
474
- // untagged still fails an accessibility audit: assistive
475
- // technology gets a flat stream with no headings, no list
476
- // structure and no reliable reading order.
477
- // roleOverride carries the quotation down to the paragraphs INSIDE a
478
- // <blockquote>. The role was only ever emitted for a blockquote
479
- // holding inline text directly - and the editor writes
480
- // <blockquote><p>...</p></blockquote>, so in practice every
481
- // blockquote we produce was tagged /P and the quotation was absent
482
- // from the accessibility tree we publish claims about.
483
- role: roleOverride || (HEADING_SCALE[tag] ? "H" + tag.charAt(1) : (tag === "blockquote" ? "BlockQuote" : "P")),
484
- listId: listId || null, listOrdered: !!listOrdered
485
- });
486
- }
487
-
488
- function walk(node, style, indent, roleOverride) {
489
- for (var i = 0; i < node.childNodes.length; i++) {
490
- var c = node.childNodes[i];
491
- if (c.nodeType === 3) {
492
- if (!(c.nodeValue || "").trim()) continue;
493
- // Loose text directly under the root still deserves a block.
494
- self.blocks.push({ type: "text", lines: self.wrap([{ text: c.nodeValue.replace(/\s+/g, " "), style: style }], self.contentWidth - indent, 1.4), indent: indent, align: "left", spaceBefore: 0, spaceAfter: self.baseSize * 0.35 });
495
- continue;
496
- }
497
- if (c.nodeType !== 1) continue;
498
- var tag = c.tagName.toLowerCase();
499
- if (tag === "script" || tag === "style" || tag === "noscript") continue;
500
-
501
- if (c.getAttribute && c.getAttribute("data-rte-page-break") !== null) { self.blocks.push({ type: "pagebreak" }); continue; }
502
- if (c.style && /always|page/.test(c.style.pageBreakBefore || c.style.breakBefore || "")) self.blocks.push({ type: "pagebreak" });
503
-
504
- if (tag === "hr") { self.blocks.push({ type: "rule", indent: indent }); continue; }
505
- if (tag === "ul" || tag === "ol") {
506
- var counter = parseInt(c.getAttribute("start"), 10);
507
- if (isNaN(counter)) counter = 1;
508
- // 2026-10-04 a lettered / Roman list is numbered the way the editor shows it (it was
509
- // always 1. 2. 3.). tools/pdf/list-markers.mjs
510
- var lst = String((c.style && c.style.listStyleType) || "").toLowerCase(), typ = c.getAttribute("type") || "";
511
- var kind = /^(lower-alpha|lower-latin)$/.test(lst) || typ === "a" ? "a"
512
- : /^(upper-alpha|upper-latin)$/.test(lst) || typ === "A" ? "A"
513
- : lst === "lower-roman" || typ === "i" ? "i"
514
- : lst === "upper-roman" || typ === "I" ? "I" : "1";
515
- // Every item of one list shares an id so the tag tree can
516
- // rebuild a single <L> around them; without it each item
517
- // becomes an isolated paragraph and a screen reader never
518
- // announces "list, 4 items".
519
- var listId = "L" + (self.listSeq = (self.listSeq || 0) + 1);
520
- for (var li = 0; li < c.childNodes.length; li++) {
521
- var item = c.childNodes[li];
522
- if (item.nodeType !== 1 || item.tagName.toLowerCase() !== "li") continue;
523
- var marker = tag === "ol" ? listNumber(counter++, kind) + "." : "\u2022";
524
- // 2026-10-15 a task item shows whether it is done: every item got the plain bullet, so a printed
525
- // checklist lost every tick. The standard fonts have no ballot box, so "[x]" / "[ ]".
526
- // tools/pdf/task-list-marks.mjs
527
- if (c.classList && c.classList.contains("rte-task-list")) marker = item.getAttribute("data-checked") === "true" ? "[x]" : "[ ]";
528
- // The item's own text, then any nested list one level in.
529
- block(item, styleOf(item, style), indent + self.baseSize * 1.6, marker, listId, tag === "ol");
530
- walkNestedLists(item, styleOf(item, style), indent + self.baseSize * 1.6);
531
- }
532
- continue;
533
- }
534
- if (tag === "table") { self.table(c, styleOf(c, style), indent); continue; }
535
- if (tag === "img") {
536
- var dims = imageDims(c, self.contentWidth - indent);
537
- if (dims.w) self.blocks.push({ type: "text", role: "Figure", alt: c.getAttribute("alt") || "", lines: [{ parts: [{ img: c, w: dims.w, h: dims.h, style: style }], width: dims.w, size: dims.h, height: dims.h, image: true }], indent: indent, align: "left", spaceBefore: 4, spaceAfter: 4 });
538
- continue;
539
- }
540
- if (isBlockTag(tag)) {
541
- var own = (tag === "blockquote" ? self.baseSize * 1.5 : 0) + cssIndentPt(c);
542
- var childRole = (tag === "blockquote") ? "BlockQuote" : roleOverride;
543
- // 2026-10-15 a block holding just an image - the shape the editor gives every inserted picture,
544
- // <p><img></p> - is a Figure with its alt text. Only an <img> sitting directly at block level was;
545
- // inside a <p> it was an inline run, so the PDF had no Figure and no /Alt (WCAG 1.1.1, PDF/UA).
546
- // tools/pdf/figure-alt.mjs
547
- var soleImgs = c.getElementsByTagName("img");
548
- if (soleImgs.length === 1 && !(c.textContent || "").replace(/[\s\u200b\u00a0]/g, "") && !hasBlockChildren(c)) {
549
- var fdims = imageDims(soleImgs[0], self.contentWidth - indent - own);
550
- if (fdims.w) {
551
- var falign = String((c.style && c.style.textAlign) || "").toLowerCase();
552
- self.blocks.push({ type: "text", role: "Figure", alt: soleImgs[0].getAttribute("alt") || "", lines: [{ parts: [{ img: soleImgs[0], w: fdims.w, h: fdims.h, style: style }], width: fdims.w, size: fdims.h, height: fdims.h, image: true }], indent: indent + own, align: falign === "center" || falign === "right" ? falign : "left", spaceBefore: 4, spaceAfter: 4 });
553
- continue;
554
- }
555
- }
556
- if (hasBlockChildren(c)) { walk(c, styleOf(c, style), indent + own, childRole); continue; }
557
- block(c, style, indent + own, null, null, false, childRole);
558
- continue;
559
- }
560
- // Inline content sitting directly under a container.
561
- block(c, style, indent, null);
562
- }
563
- }
564
-
565
- function walkNestedLists(item, style, indent) {
566
- for (var i = 0; i < item.childNodes.length; i++) {
567
- var c = item.childNodes[i];
568
- if (c.nodeType !== 1) continue;
569
- var tag = c.tagName.toLowerCase();
570
- if (tag === "ul" || tag === "ol") walk({ childNodes: [c] }, style, indent);
571
- }
572
- }
573
-
574
- walk(root, base, 0);
575
- return this.blocks;
576
- };
577
-
578
- function isBlockTag(tag) {
579
- return /^(p|div|h[1-6]|blockquote|pre|section|article|header|footer|main|aside|figure|figcaption|dl|dd|dt|li|address)$/.test(tag);
580
- }
581
- function hasBlockChildren(node) {
582
- for (var i = 0; i < node.childNodes.length; i++) {
583
- var c = node.childNodes[i];
584
- if (c.nodeType === 1 && (isBlockTag(c.tagName.toLowerCase()) || /^(ul|ol|table|hr)$/.test(c.tagName.toLowerCase()))) return true;
585
- }
586
- return false;
587
- }
588
-
589
- Layout.prototype.table = function (node, style, indent) {
590
- var self = this;
591
- var rows = [];
592
- var trs = node.getElementsByTagName("tr");
593
- var maxCells = 0;
594
- // 2026-10-15 rowspan: covered[col] = rows a cell above still covers there. Cells in the rows below skip those
595
- // columns (they were placed from column 0, shifting a row left under the wrong header), and each covered
596
- // position becomes an empty placeholder cell so the grid stays aligned. tools/pdf/table-rowspan.mjs
597
- var covered = {};
598
- for (var r = 0; r < trs.length; r++) {
599
- var cells = [];
600
- for (var c = 0; c < trs[r].childNodes.length; c++) {
601
- var cell = trs[r].childNodes[c];
602
- if (cell.nodeType !== 1) continue;
603
- var tag = cell.tagName.toLowerCase();
604
- if (tag !== "td" && tag !== "th") continue;
605
- // 2026-10-15 colspan: a cell starts at its grid column and spans its columns. tools/pdf/table-colspan.mjs
606
- var span = Math.max(1, Math.min(50, parseInt(cell.getAttribute("colspan"), 10) || 1));
607
- var rspan = Math.max(1, Math.min(500, parseInt(cell.getAttribute("rowspan"), 10) || 1));
608
- cells.push({ node: cell, header: tag === "th", span: span, rowspan: rspan });
609
- }
610
- var placed = [], gridCols = 0;
611
- for (var gc = 0; gc < cells.length; gc++) {
612
- while (covered[gridCols] > 0) { placed.push({ placeholder: true, start: gridCols, span: 1, header: false }); gridCols++; }
613
- cells[gc].start = gridCols;
614
- placed.push(cells[gc]);
615
- if (cells[gc].rowspan > 1) for (var rc = gridCols; rc < gridCols + cells[gc].span; rc++) covered[rc] = cells[gc].rowspan;
616
- gridCols += cells[gc].span;
617
- }
618
- // covered columns after the row's last cell
619
- for (var tail in covered) if (+tail >= gridCols && covered[tail] > 0) { placed.push({ placeholder: true, start: +tail, span: 1, header: false }); if (+tail + 1 > gridCols) gridCols = +tail + 1; }
620
- // this row is done: each covering cell has one row fewer left (a cell covering from THIS row still needs
621
- // its own count, so it was set to rowspan and drops to rowspan - 1 here)
622
- for (var dc in covered) { covered[dc]--; if (covered[dc] <= 0) delete covered[dc]; }
623
- placed.sort(function (p1, p2) { return p1.start - p2.start; });
624
- cells = cells.length ? placed : cells;
625
- if (cells.length) { rows.push(cells); if (gridCols > maxCells) maxCells = gridCols; }
626
- }
627
- if (!rows.length) return;
628
-
629
- var width = this.contentWidth - indent;
630
- var colWidth = width / maxCells;
631
- // 2026-10-15 column widths follow the <colgroup> (% or px), scaled to the table's width on the page: every
632
- // table was split into equal columns. A table without one keeps equal columns. tools/pdf/table-col-widths.mjs
633
- var colWidths = [], cg = [], cw = 0;
634
- for (var ck = 0; ck < node.children.length; ck++) if (node.children[ck].tagName && node.children[ck].tagName.toLowerCase() === "colgroup") {
635
- for (var cj = 0; cj < node.children[ck].children.length; cj++) if (node.children[ck].children[cj].tagName.toLowerCase() === "col") cg.push(node.children[ck].children[cj]);
636
- }
637
- if (cg.length === maxCells) {
638
- for (var cq = 0; cq < cg.length; cq++) {
639
- var cv = (cg[cq].style && cg[cq].style.width) || cg[cq].getAttribute("width") || "", cm = /^\s*([\d.]+)(%|px)?\s*$/.exec(cv);
640
- if (!cm || !(parseFloat(cm[1]) > 0)) { colWidths = []; break; }
641
- colWidths.push(parseFloat(cm[1]));
642
- }
643
- for (var cs2 = 0; cs2 < colWidths.length; cs2++) cw += colWidths[cs2];
644
- if (cw > 0) colWidths = colWidths.map(function (v) { return v / cw * width; });
645
- }
646
- if (colWidths.length !== maxCells) { colWidths = []; for (var ce = 0; ce < maxCells; ce++) colWidths.push(colWidth); }
647
- function spanWidth(start, span) { var s = 0; for (var sw = start; sw < start + span && sw < colWidths.length; sw++) s += colWidths[sw]; return s; }
648
- var pad = 4;
649
- var laid = [];
650
- for (var i = 0; i < rows.length; i++) {
651
- var out = [], tallest = 0;
652
- for (var j = 0; j < rows[i].length; j++) {
653
- if (rows[i][j].placeholder) { out.push({ lines: [], header: false, start: rows[i][j].start, span: 1, placeholder: true }); continue; }
654
- var cs = styleOf(rows[i][j].node, style);
655
- if (rows[i][j].header) cs.bold = true;
656
- var lines = self.wrap(self.runsOf(rows[i][j].node, cs), spanWidth(rows[i][j].start, rows[i][j].span) - pad * 2, 1.35);
657
- var h = 0;
658
- for (var k = 0; k < lines.length; k++) h += lines[k].height;
659
- if (h > tallest) tallest = h;
660
- out.push({ lines: lines, header: rows[i][j].header, start: rows[i][j].start, span: rows[i][j].span, rowspan: rows[i][j].rowspan || 1 });
661
- }
662
- laid.push({ cells: out, height: tallest + pad * 2, header: rows[i][0] && !rows[i][0].placeholder && rows[i][0].header });
663
- }
664
- this.blocks.push({
665
- type: "table", rows: laid, colWidth: colWidth, colWidths: colWidths, cols: maxCells,
666
- indent: indent, pad: pad,
667
- spaceBefore: this.baseSize * 0.5, spaceAfter: this.baseSize * 0.5
668
- });
669
- };
670
-
671
- // ------------------------------------------------------------- rendering
672
- // 2026-10-15 the document language, read by the tagger to mark blocks in other languages
673
- var pdfDocLang = "en";
674
- function buildPdf(options) {
675
- pdfDocLang = String((options && options.lang) || document.documentElement.lang || "en").toLowerCase();
676
- var familyKey = String(options.font || config.pdfExportFont || "helvetica").toLowerCase();
677
- if (!FONT_FAMILIES[familyKey]) familyKey = "helvetica";
678
- var baseSize = Number(options.fontSize || config.pdfExportBaseFontSize || 11);
679
- if (!isFinite(baseSize) || baseSize <= 0) baseSize = 11;
680
- var geom = pageGeometry(options);
681
-
682
- // getHTMLCode() is the SERIALIZED document: presentational plugins strip
683
- // their own chrome from it. Reading the live DOM instead would bake page
684
- // overlays, watermarks and formatting marks into the PDF.
685
- var host = document.createElement("div");
686
- host.innerHTML = editor.getHTMLCode() || "";
687
- var liveLists = editor.getEditable().querySelectorAll("ol,ul"), exportLists = host.querySelectorAll("ol,ul");
688
- if (liveLists.length === exportLists.length) for (var li = 0; li < liveLists.length; li++) {
689
- var listStyle = liveLists[li].ownerDocument.defaultView.getComputedStyle(liveLists[li]).listStyleType;
690
- if (listStyle) exportLists[li].style.listStyleType = listStyle;
691
- }
692
-
693
- var layout = new Layout(geom, baseSize, familyKey);
694
- var blocks = layout.run(host);
695
-
696
- var pages = paginate(blocks, geom, baseSize);
697
- return emit(pages, geom, familyKey, baseSize, options);
698
- }
699
-
700
- function paginate(blocks, geom, baseSize) {
701
- var usableTop = geom.height - geom.margin.top;
702
- var usableBottom = geom.margin.bottom;
703
- var pages = [], current = [], y = usableTop;
704
-
705
- function newPage() { pages.push(current); current = []; y = usableTop; }
706
-
707
- for (var i = 0; i < blocks.length; i++) {
708
- var b = blocks[i];
709
- if (b.type === "pagebreak") { if (current.length) newPage(); continue; }
710
-
711
- if (b.type === "rule") {
712
- if (y - baseSize < usableBottom) newPage();
713
- y -= baseSize * 0.6;
714
- current.push({ op: "rule", y: y, indent: b.indent });
715
- y -= baseSize * 0.6;
716
- continue;
717
- }
718
-
719
- if (b.type === "table") {
720
- y -= b.spaceBefore;
721
- for (var r = 0; r < b.rows.length; r++) {
722
- var row = b.rows[r];
723
- if (y - row.height < usableBottom) {
724
- newPage();
725
- // Repeat the header row on the continuation page, or the
726
- // rest of the table arrives as unlabelled numbers.
727
- if (b.rows[0] && b.rows[0].header && r > 0) {
728
- current.push({ op: "row", row: b.rows[0], y: y, block: b });
729
- y -= b.rows[0].height;
730
- }
731
- }
732
- current.push({ op: "row", row: row, y: y, block: b });
733
- y -= row.height;
734
- }
735
- y -= b.spaceAfter;
736
- continue;
737
- }
738
-
739
- y -= b.spaceBefore;
740
- for (var l = 0; l < b.lines.length; l++) {
741
- var line = b.lines[l];
742
- if (y - line.height < usableBottom) newPage();
743
- current.push({ op: "line", line: line, y: y, block: b, first: l === 0 });
744
- y -= line.height;
745
- }
746
- y -= b.spaceAfter;
747
-
748
- // keepWithNext: if a heading ended up as the last thing on the page,
749
- // move it to the next one rather than orphaning it.
750
- if (b.keepWithNext && current.length && blocks[i + 1] && blocks[i + 1].type !== "pagebreak") {
751
- var nextHeight = estimateFirstLine(blocks[i + 1]);
752
- if (y - nextHeight < usableBottom) {
753
- var moved = [];
754
- while (current.length && current[current.length - 1].block === b) moved.unshift(current.pop());
755
- if (current.length) {
756
- newPage();
757
- for (var m = 0; m < moved.length; m++) {
758
- moved[m].y = y;
759
- current.push(moved[m]);
760
- y -= moved[m].line.height;
761
- }
762
- } else {
763
- // Already at the top of a page: nothing to gain by moving.
764
- for (var m2 = 0; m2 < moved.length; m2++) current.push(moved[m2]);
765
- }
766
- }
767
- }
768
- }
769
- if (current.length || !pages.length) pages.push(current);
770
- return pages;
771
- }
772
-
773
- function estimateFirstLine(block) {
774
- if (block.type === "table") return block.rows[0] ? block.rows[0].height : 0;
775
- if (block.lines && block.lines[0]) return block.lines[0].height;
776
- return 0;
777
- }
778
-
779
- function emit(pages, geom, familyKey, baseSize, options) {
780
- var pdf = new PdfWriter();
781
- var fam = FONT_FAMILIES[familyKey];
782
- var mono = FONT_FAMILIES.courier;
783
-
784
- // Font objects: four faces of the body family plus four of Courier, each
785
- // carrying the widths this layout was computed with.
786
- var fontIds = {}, fontRes = [];
787
- function addFont(name, key, styleIndex, alias) {
788
- var widths = widthsFor(key, styleIndex);
789
- var id = pdf.add("<< /Type /Font /Subtype /Type1 /BaseFont /" + name +
790
- " /Encoding /WinAnsiEncoding /FirstChar " + FIRST_CHAR + " /LastChar " + LAST_CHAR +
791
- " /Widths [" + widths.join(" ") + "] >>");
792
- fontIds[alias] = id;
793
- fontRes.push("/" + alias + " " + id + " 0 R");
794
- }
795
- for (var s = 0; s < 4; s++) {
796
- addFont(fam.faces[s], familyKey, s, "F" + s);
797
- addFont(mono.faces[s], "courier", s, "M" + s);
798
- }
799
-
800
- var images = {}, imageRes = [];
801
- function imageRef(img) {
802
- var src = img.getAttribute("src") || "";
803
- if (images[src]) return images[src];
804
- var data = encodeImage(img);
805
- if (!data) return null;
806
- var id = pdf.add({
807
- dict: "<< /Type /XObject /Subtype /Image /Width " + data.width + " /Height " + data.height +
808
- " /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length 0 >>",
809
- stream: data.bytes
810
- });
811
- var alias = "Im" + (imageRes.length + 1);
812
- images[src] = { id: id, alias: alias };
813
- imageRes.push("/" + alias + " " + id + " 0 R");
814
- return images[src];
815
- }
816
-
817
- var pageIds = [], contentIds = [], annotIds = [];
818
- for (var p = 0; p < pages.length; p++) {
819
- pageIds.push(pdf.alloc());
820
- contentIds.push(pdf.alloc());
821
- annotIds.push([]);
822
- }
823
- var pagesId = pdf.alloc();
824
-
825
- var allMarks = [], heads = [], linkAnnots = [];
826
- for (var pi = 0; pi < pages.length; pi++) {
827
- var tagger = new Tagger(pi);
828
- var draw = renderPage(pages[pi], geom, familyKey, baseSize, imageRef, pi + 1, pages.length, options, tagger);
829
- for (var hd = 0; hd < (draw.headings || []).length; hd++) { draw.headings[hd].page = pi; heads.push(draw.headings[hd]); }
830
- pdf.set(contentIds[pi], { dict: "<< /Length 0 >>", stream: draw.content });
831
- allMarks.push(draw.marks);
832
- for (var a = 0; a < draw.links.length; a++) {
833
- var lk = draw.links[a];
834
- // 2026-10-15 each link annotation is in the tag tree: /StructParent (keys after the pages'), /Contents
835
- // naming it, and a Link element built in buildStructTree. They were click targets a screen reader
836
- // could not find (PDF/UA 7.18, Matterhorn 28-011 / 28-004). tools/pdf/link-tags.mjs
837
- var lsp = pages.length + linkAnnots.length;
838
- var lid = pdf.add("<< /Type /Annot /Subtype /Link /Rect [" +
839
- fixed(lk.x0) + " " + fixed(lk.y0) + " " + fixed(lk.x1) + " " + fixed(lk.y1) +
840
- "] /Border [0 0 0] /StructParent " + lsp + " /Contents (" + escapeString(toWinAnsi(String(lk.text || lk.href).replace(/\s+/g, " ").trim() || lk.href)) + ")" +
841
- " /A << /S /URI /URI (" + escapeString(toWinAnsi(lk.href)) + ") >> >>");
842
- annotIds[pi].push(lid);
843
- linkAnnots.push({ id: lid, page: pi, key: lsp, owner: lk.owner });
844
- }
845
- }
846
-
847
- var struct = buildStructTree(pdf, allMarks, pageIds, linkAnnots);
848
-
849
- for (var pj = 0; pj < pages.length; pj++) {
850
- pdf.set(pageIds[pj], "<< /Type /Page /Parent " + pagesId + " 0 R /MediaBox [0 0 " +
851
- fixed(geom.width) + " " + fixed(geom.height) + "] /Resources << /Font << " + fontRes.join(" ") + " >>" +
852
- (imageRes.length ? " /XObject << " + imageRes.join(" ") + " >>" : "") +
853
- " >> /Contents " + contentIds[pj] + " 0 R /StructParents " + pj +
854
- (annotIds[pj].length ? " /Annots [" + annotIds[pj].map(function (id) { return id + " 0 R"; }).join(" ") + "] /Tabs /S" : "") +
855
- " >>");
856
- }
857
- pdf.set(pagesId, "<< /Type /Pages /Count " + pages.length + " /Kids [" +
858
- pageIds.map(function (id) { return id + " 0 R"; }).join(" ") + "] >>");
859
-
860
- // 2026-10-15 bookmarks from the headings, nested by level, each opening its heading's page at the heading
861
- // (Word and Chrome export them; a long report had nothing but scrolling - WCAG 2.4.5, PDF2).
862
- // tools/pdf/outline-bookmarks.mjs
863
- var outlineId = 0;
864
- if (heads.length) {
865
- outlineId = pdf.alloc();
866
- var oroot = { id: outlineId, kids: [] }, ostack = [oroot], onodes = [];
867
- for (var oh = 0; oh < heads.length; oh++) {
868
- var on = { h: heads[oh], id: pdf.alloc(), kids: [], parent: null };
869
- while (ostack.length > 1 && ostack[ostack.length - 1].h.level >= on.h.level) ostack.pop();
870
- on.parent = ostack[ostack.length - 1];
871
- on.parent.kids.push(on);
872
- ostack.push(on);
873
- onodes.push(on);
874
- }
875
- var ocount = function (n) { var c = n.kids.length; for (var k = 0; k < n.kids.length; k++) c += ocount(n.kids[k]); return c; };
876
- for (var oi = 0; oi < onodes.length; oi++) {
877
- var n = onodes[oi], sib = n.parent.kids, at = sib.indexOf(n);
878
- pdf.set(n.id, "<< /Title (" + escapeString(toWinAnsi(n.h.title)) + ") /Parent " + n.parent.id + " 0 R" +
879
- (at > 0 ? " /Prev " + sib[at - 1].id + " 0 R" : "") + (at < sib.length - 1 ? " /Next " + sib[at + 1].id + " 0 R" : "") +
880
- (n.kids.length ? " /First " + n.kids[0].id + " 0 R /Last " + n.kids[n.kids.length - 1].id + " 0 R /Count " + ocount(n) : "") +
881
- " /Dest [" + pageIds[n.h.page] + " 0 R /XYZ null " + fixed(n.h.y) + " null] >>");
882
- }
883
- pdf.set(outlineId, "<< /Type /Outlines /First " + oroot.kids[0].id + " 0 R /Last " + oroot.kids[oroot.kids.length - 1].id + " 0 R /Count " + ocount(oroot) + " >>");
884
- }
885
-
886
- var title = options.title || config.pdfExportTitle || document.title || "Document";
887
- var author = options.author || config.pdfExportAuthor || "";
888
- // /MarkInfo and /StructTreeRoot declare the file tagged. DisplayDocTitle
889
- // makes the viewer show the document's title rather than its filename,
890
- // which PDF/UA requires and which is the difference between a screen
891
- // reader announcing "Quarterly Report" and "Export-260802-1431.pdf".
892
- var catalogId = pdf.add("<< /Type /Catalog /Pages " + pagesId + " 0 R" +
893
- " /Lang (" + escapeString(String(options.lang || document.documentElement.lang || "en")) + ")" +
894
- " /MarkInfo << /Marked true >>" +
895
- (outlineId ? " /Outlines " + outlineId + " 0 R" : "") +
896
- " /StructTreeRoot " + struct.rootId + " 0 R" +
897
- " /ViewerPreferences << /DisplayDocTitle true >> >>");
898
- var infoId = pdf.add("<< /Title (" + escapeString(toWinAnsi(String(title))) + ")" +
899
- (author ? " /Author (" + escapeString(toWinAnsi(String(author))) + ")" : "") +
900
- " /Producer (RichTextEditor) /Creator (RichTextEditor) /CreationDate (" + pdfDate(new Date()) + ") >>");
901
-
902
- return pdf.build(catalogId, infoId);
903
- }
904
-
905
- // Turn the per-page marked-content records into a structure tree.
906
- //
907
- // The tree is built from the marks rather than from the block list because a
908
- // single block can be split across pages: its structure element then holds
909
- // several marked-content references, each naming the page it lives on.
910
- function buildStructTree(pdf, allMarks, pageIds, linkAnnots) {
911
- var rootId = pdf.alloc();
912
- var nodes = []; // { id, role, kids, extra, children, parent }
913
- var parentTree = []; // per page: array of struct ids by MCID
914
-
915
- function node(role, extra) {
916
- var n = { id: pdf.alloc(), role: role, kids: [], extra: extra || "", children: [], parent: null, seq: [] };
917
- nodes.push(n);
918
- return n;
919
- }
920
-
921
- var docNode = node("Document");
922
- var listNodes = new Map(); // listId -> L node
923
- var itemNodes = new Map(); // block -> LI node
924
- var labelNodes = new Map(); // block -> Lbl node
925
- var bodyNodes = new Map(); // block -> P / LBody / Figure node
926
- var tableNodes = new Map(); // block -> Table node
927
- var rowNodes = new Map(); // row -> TR node
928
- var cellNodes = new Map(); // row -> { col -> TD/TH node }
929
- var linkNodes = new Map(); // link owner -> Link node (2026-10-17, tools/pdf/link-order.mjs)
930
-
931
- for (var p = 0; p < allMarks.length; p++) {
932
- var marks = allMarks[p];
933
- parentTree[p] = [];
934
- for (var m = 0; m < marks.length; m++) {
935
- var owner = marks[m].owner, mcid = marks[m].mcid;
936
- var target = resolve(owner);
1
+ if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
2
+
3
+ // 2026-08-02 PDF export that produces REAL TEXT.
4
+ //
5
+ // The existing html2pdf path renders the document with html2canvas and drops a
6
+ // JPEG onto each page. It is pixel-accurate, which is genuinely the right answer
7
+ // when the layout is the point — but the resulting PDF is a picture:
8
+ //
9
+ // - nothing is selectable, copyable or searchable
10
+ // - screen readers find an empty document (a hard accessibility failure, and
11
+ // an outright blocker for anyone shipping into government or education)
12
+ // - links are painted, not clickable
13
+ // - a text-only page costs hundreds of KB instead of a few
14
+ // - zooming or printing shows resampling artefacts
15
+ //
16
+ // This writes the PDF file format directly: no library, no canvas, no network.
17
+ // Both exporters stay available and neither changes the other's behaviour —
18
+ // `exportpdf` is vector, `html2pdf` remains raster.
19
+ //
20
+ // Why writing PDF by hand is reasonable here: a text PDF is a small object graph
21
+ // plus a content stream, and the 14 standard fonts need no embedding at all. The
22
+ // hard part of a PDF generator is font subsetting, and using the base-14 fonts
23
+ // removes it entirely.
24
+ //
25
+ // API:
26
+ // editor.getPdfBytes(options) -> Uint8Array
27
+ // editor.exportToPdf(filename, options)-> triggers a download
28
+ // Command: exec_command "exportpdf".
29
+ // Config:
30
+ // config.pdfExport = false // disable
31
+ // config.pdfExportFileName = "report"
32
+ // config.pdfExportFont = "helvetica" // helvetica | times | courier
33
+ // config.pdfExportBaseFontSize = 11 // points
34
+ // config.pdfExportTitle / pdfExportAuthor // document metadata
35
+ RTE_DefaultConfig.plugin_pdfexport = RTE_Plugin_PdfExport;
36
+ if (typeof RTE_DefaultConfig.pdfExport === "undefined") RTE_DefaultConfig.pdfExport = true;
37
+
38
+ function RTE_Plugin_PdfExport() {
39
+ // An ordered list item's number in its list style: 1, a..z then aa (CSS lower-alpha), i/I roman.
40
+ function listNumber(n, kind) {
41
+ if (kind === "a" || kind === "A") {
42
+ var t = "";
43
+ for (var k = n; k > 0; k = Math.floor((k - 1) / 26)) t = String.fromCharCode(97 + (k - 1) % 26) + t;
44
+ return t ? (kind === "A" ? t.toUpperCase() : t) : String(n); // 0 and below: a number, never ""
45
+ }
46
+ if ((kind === "i" || kind === "I") && n > 0 && n < 4000) {
47
+ var r = "", vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1], sy = ["m", "cm", "d", "cd", "c", "xc", "l", "xl", "x", "ix", "v", "iv", "i"];
48
+ for (var q = 0; q < vals.length; q++) while (n >= vals[q]) { r += sy[q]; n -= vals[q]; }
49
+ return kind === "I" ? r.toUpperCase() : r;
50
+ }
51
+ return String(n);
52
+ }
53
+
54
+ var obj = this;
55
+ var config, editor;
56
+
57
+ obj.PluginName = "PdfExport";
58
+
59
+ obj.InitConfig = function (argconfig) { config = argconfig; };
60
+
61
+ obj.InitEditor = function (argeditor) {
62
+ editor = argeditor;
63
+ if (config.pdfExport === false) return;
64
+
65
+ editor.getPdfBytes = function (options) { return buildPdf(options || {}); };
66
+ // 2026-10-26 The standard PDF fonts encode Western European Latin only (WinAnsi): every other character -
67
+ // Chinese, Japanese, Korean, Cyrillic, Greek, Arabic... - was written as "?", silently. Until fonts can be
68
+ // embedded: getPdfUnsupportedText() says which characters a vector export would lose, and exportToPdf() (the
69
+ // toolbar's Export PDF) hands such a document to the html2pdf raster export when that plugin is loaded, which
70
+ // draws every script. Set pdfExportRasterFallback = false to always write the vector PDF.
71
+ // tools/pdf/pdf-nonlatin-fallback.mjs
72
+ editor.getPdfUnsupportedText = function (options) { return unsupportedText(options || {}); };
73
+ // Fetches a TrueType font from a URL you choose and uses it for the text the standard fonts cannot draw.
74
+ // Resolves true when it is usable; nothing is fetched unless this is called. tools/pdf/pdf-unicode-font.mjs
75
+ editor.loadPdfFont = function (url) {
76
+ return fetch(url).then(function (r) { if (!r.ok) throw new Error("pdfexport: font " + url + " returned " + r.status); return r.arrayBuffer(); })
77
+ .then(function (buf) { var bytes = new Uint8Array(buf); parseTrueType(bytes); config.pdfExportUnicodeFont = bytes; return true; });
78
+ };
79
+ editor.exportToPdf = function (filename, options) {
80
+ var lost = unsupportedText(options || {});
81
+ if (lost) {
82
+ var raster = config.pdfExportRasterFallback !== false && editor.toolbarFactoryMap && editor.toolbarFactoryMap["html2pdf"];
83
+ if (window.console && console.warn) {
84
+ console.warn("pdfexport: the vector PDF cannot encode " + lost.length + " character(s) of this document (" +
85
+ lost.slice(0, 20) + (lost.length > 20 ? "..." : "") + "); its standard fonts cover Western European Latin only. " +
86
+ (raster ? "Exporting with the html2pdf raster export instead." : "They are written as \"?\". Load the html2pdf plugin for a raster export that keeps them."));
87
+ }
88
+ if (raster) { editor.execCommand("html2pdf"); return null; }
89
+ }
90
+ var bytes = buildPdf(options || {});
91
+ var base = sanitizeName(filename) || sanitizeName(config.pdfExportFileName) || defaultBase();
92
+ return download(bytes, base + ".pdf");
93
+ };
94
+ editor.downloadPdf = editor.exportToPdf;
95
+
96
+ editor.attachEvent("exec_command_exportpdf", function (state) {
97
+ state.returnValue = true;
98
+ state.stopBubble = true;
99
+ try { editor.exportToPdf(); }
100
+ catch (e) { if (window.console) console.error("pdfexport:", e); }
101
+ });
102
+
103
+ // The slash-menu entry lives in slashcommand.js, gated on
104
+ // `typeof editor.exportToPdf === "function"`. Registering from here
105
+ // instead would silently do nothing: plugins initialise in bundle
106
+ // order, "pdfexport" sorts before "slashcommand", and
107
+ // editor.slashCommands does not exist yet at this point.
108
+ };
109
+
110
+ // ---------------------------------------------------------------- units
111
+ var PT_PER_IN = 72;
112
+ var PAGE_SIZES = { // points, portrait
113
+ letter: [612, 792], legal: [612, 1008], tabloid: [792, 1224],
114
+ a3: [842, 1191], a4: [595, 842], a5: [420, 595]
115
+ };
116
+
117
+ function toPoints(v, fallback) {
118
+ if (typeof v === "number" && isFinite(v)) return v * PT_PER_IN; // bare number = inches
119
+ if (typeof v !== "string") return fallback;
120
+ var m = /^\s*(-?[\d.]+)\s*(mm|cm|in|px|pt)?\s*$/.exec(v);
121
+ if (!m) return fallback;
122
+ var n = parseFloat(m[1]);
123
+ if (!isFinite(n)) return fallback;
124
+ switch (m[2]) {
125
+ case "mm": return n * PT_PER_IN / 25.4;
126
+ case "cm": return n * PT_PER_IN / 2.54;
127
+ case "pt": return n;
128
+ case "px": return n * 0.75; // CSS px at 96dpi
129
+ default: return n * PT_PER_IN;
130
+ }
131
+ }
132
+
133
+ function pageGeometry(options) {
134
+ var setup = null;
135
+ try { if (typeof editor.getDocumentPageSetup === "function") setup = editor.getDocumentPageSetup(); }
136
+ catch (e) { setup = null; }
137
+ setup = setup || {};
138
+ var fmt = String(options.format || setup.format || "letter").toLowerCase();
139
+ var size = PAGE_SIZES[fmt] || PAGE_SIZES.letter;
140
+ var landscape = String(options.orientation || setup.orientation || "").toLowerCase() === "landscape";
141
+ var w = landscape ? size[1] : size[0];
142
+ var h = landscape ? size[0] : size[1];
143
+ var m = options.margins || setup.margins || {};
144
+ var half = PT_PER_IN / 2;
145
+ return {
146
+ width: w, height: h,
147
+ margin: {
148
+ top: toPoints(m.top, half), right: toPoints(m.right, half),
149
+ bottom: toPoints(m.bottom, half), left: toPoints(m.left, half)
150
+ }
151
+ };
152
+ }
153
+
154
+ // ---------------------------------------------------------------- fonts
155
+ //
156
+ // The base-14 fonts need no embedding, but a viewer would then lay text out
157
+ // with ITS metrics while this code wrapped lines using its own. The two
158
+ // disagree and text overruns the margin. Fixing it by shipping AFM width
159
+ // tables would add ~1500 lines of data; instead the widths are measured here
160
+ // and written into the font object as /Widths, which a viewer is required to
161
+ // honour. Layout and rendering then use the same numbers by construction.
162
+ //
163
+ // Measurement is accurate because the base-14 fonts are metric-compatible
164
+ // with fonts every browser has: Helvetica/Arial, Times/Times New Roman,
165
+ // Courier/Courier New were designed to share advance widths.
166
+ var FONT_FAMILIES = {
167
+ helvetica: { css: "Arial, Helvetica, sans-serif", faces: ["Helvetica", "Helvetica-Bold", "Helvetica-Oblique", "Helvetica-BoldOblique"] },
168
+ times: { css: '"Times New Roman", Times, serif', faces: ["Times-Roman", "Times-Bold", "Times-Italic", "Times-BoldItalic"] },
169
+ courier: { css: '"Courier New", Courier, monospace', faces: ["Courier", "Courier-Bold", "Courier-Oblique", "Courier-BoldOblique"] }
170
+ };
171
+ var FIRST_CHAR = 32, LAST_CHAR = 255;
172
+
173
+ var widthCache = {};
174
+ function widthsFor(familyKey, styleIndex) {
175
+ var key = familyKey + ":" + styleIndex;
176
+ if (widthCache[key]) return widthCache[key];
177
+ var fam = FONT_FAMILIES[familyKey] || FONT_FAMILIES.helvetica;
178
+ var canvas = widthsFor.canvas || (widthsFor.canvas = document.createElement("canvas"));
179
+ var g = canvas.getContext("2d");
180
+ // 1000 units per em is the PDF glyph space; measuring at 100px and
181
+ // scaling by 10 keeps sub-unit rounding away from the result.
182
+ var weight = (styleIndex & 1) ? "bold " : "";
183
+ var slant = (styleIndex & 2) ? "italic " : "";
184
+ g.font = weight + slant + "100px " + fam.css;
185
+ var out = [];
186
+ for (var c = FIRST_CHAR; c <= LAST_CHAR; c++) {
187
+ var w = g.measureText(String.fromCharCode(c)).width;
188
+ out.push(Math.round(w * 10));
189
+ }
190
+ widthCache[key] = out;
191
+ return out;
192
+ }
193
+
194
+ function charWidth(familyKey, styleIndex, code) {
195
+ if (code < FIRST_CHAR || code > LAST_CHAR) code = 63; // '?'
196
+ return widthsFor(familyKey, styleIndex)[code - FIRST_CHAR] || 500;
197
+ }
198
+
199
+ function textWidth(str, familyKey, styleIndex, size) {
200
+ var total = 0;
201
+ for (var i = 0; i < str.length; i++) total += charWidth(familyKey, styleIndex, str.charCodeAt(i));
202
+ return total * size / 1000;
203
+ }
204
+
205
+ // ------------------------------------------------------------ embedded Unicode font
206
+ //
207
+ // 2026-10-26 The base-14 fonts encode Western European Latin only. With a TrueType font supplied
208
+ // (config.pdfExportUnicodeFont / options.unicodeFont: the bytes of a .ttf, or editor.loadPdfFont(url)),
209
+ // text the base fonts cannot draw - Chinese, Japanese, Korean, Cyrillic, Greek... - is written in that font
210
+ // instead: a Type0 / CIDFontType2 font with Identity-H encoding and a ToUnicode map, so it stays vector,
211
+ // selectable and searchable. The font is subset: every glyph keeps its ID (so CIDToGIDMap stays Identity)
212
+ // but only the outlines the document uses are kept. tools/pdf/pdf-unicode-font.mjs
213
+ var activeUni = null; // { font, used: { gid: codepoint } } while one export runs
214
+ var parsedFonts = []; // [{ bytes, font }] - a parse is reused for the same bytes
215
+
216
+ function u16(b, o) { return (b[o] << 8) | b[o + 1]; }
217
+ function s16(b, o) { var v = u16(b, o); return v & 0x8000 ? v - 0x10000 : v; }
218
+ function u32(b, o) { return ((b[o] << 24) >>> 0) + (b[o + 1] << 16) + (b[o + 2] << 8) + b[o + 3]; }
219
+
220
+ function parseTrueType(input) {
221
+ var b = input instanceof Uint8Array ? input : new Uint8Array(input);
222
+ for (var pi = 0; pi < parsedFonts.length; pi++) if (parsedFonts[pi].bytes === b) return parsedFonts[pi].font;
223
+ var tag0 = u32(b, 0);
224
+ if (tag0 !== 0x00010000 && tag0 !== 0x74727565) {
225
+ throw new Error(tag0 === 0x4F54544F ? "pdfexport: the Unicode font has CFF (PostScript) outlines; supply a TrueType (.ttf) font"
226
+ : tag0 === 0x74746366 ? "pdfexport: the Unicode font is a collection (.ttc); supply a single .ttf font"
227
+ : "pdfexport: the Unicode font is not a TrueType font");
228
+ }
229
+ var tables = {}, n = u16(b, 4);
230
+ for (var i = 0; i < n; i++) {
231
+ var rec = 12 + i * 16;
232
+ var tag = String.fromCharCode(b[rec], b[rec + 1], b[rec + 2], b[rec + 3]);
233
+ tables[tag] = { offset: u32(b, rec + 8), length: u32(b, rec + 12) };
234
+ }
235
+ ["head", "hhea", "hmtx", "maxp", "cmap", "loca", "glyf"].forEach(function (t) {
236
+ if (!tables[t]) throw new Error("pdfexport: the Unicode font has no '" + t + "' table");
237
+ });
238
+ var head = tables.head.offset, hhea = tables.hhea.offset;
239
+ var font = {
240
+ bytes: b, tables: tables,
241
+ units: u16(b, head + 18),
242
+ bbox: [s16(b, head + 36), s16(b, head + 38), s16(b, head + 40), s16(b, head + 42)],
243
+ longLoca: s16(b, head + 50) === 1,
244
+ numGlyphs: u16(b, tables.maxp.offset + 4),
245
+ numHMetrics: u16(b, hhea + 34),
246
+ ascent: s16(b, hhea + 4), descent: s16(b, hhea + 6), capHeight: 0, italicAngle: 0
247
+ };
248
+ if (tables["OS/2"]) {
249
+ var os2 = tables["OS/2"].offset;
250
+ font.ascent = s16(b, os2 + 68); font.descent = s16(b, os2 + 70);
251
+ if (u16(b, os2) >= 2 && tables["OS/2"].length >= 90) font.capHeight = s16(b, os2 + 88);
252
+ }
253
+ if (!font.capHeight) font.capHeight = font.ascent;
254
+ if (tables.post) font.italicAngle = s16(b, tables.post.offset + 4);
255
+ // cmap: a Unicode subtable, format 12 (full range) preferred over format 4 (BMP)
256
+ var cmap = tables.cmap.offset, best = null, bestFmt = 0;
257
+ for (var c = 0, nc = u16(b, cmap + 2); c < nc; c++) {
258
+ var pid = u16(b, cmap + 4 + c * 8), eid = u16(b, cmap + 6 + c * 8), off = cmap + u32(b, cmap + 8 + c * 8);
259
+ var fmt = u16(b, off);
260
+ var unicode = pid === 0 || (pid === 3 && (eid === 1 || eid === 10));
261
+ if (!unicode || (fmt !== 4 && fmt !== 12)) continue;
262
+ if (fmt > bestFmt) { best = off; bestFmt = fmt; }
263
+ }
264
+ if (!best) throw new Error("pdfexport: the Unicode font has no Unicode character map");
265
+ var lookup = {};
266
+ font.gidFor = function (cp) {
267
+ if (lookup[cp] !== undefined) return lookup[cp];
268
+ var g = 0;
269
+ if (bestFmt === 12) {
270
+ var groups = u32(b, best + 12);
271
+ for (var k = 0; k < groups; k++) {
272
+ var go = best + 16 + k * 12, start = u32(b, go), end = u32(b, go + 4);
273
+ if (cp >= start && cp <= end) { g = u32(b, go + 8) + (cp - start); break; }
274
+ }
275
+ } else if (cp <= 0xFFFF) {
276
+ var segX2 = u16(b, best + 6), ends = best + 14, starts = ends + segX2 + 2, deltas = starts + segX2, ranges = deltas + segX2;
277
+ for (var s = 0; s < segX2 / 2; s++) {
278
+ if (cp > u16(b, ends + s * 2)) continue;
279
+ var st = u16(b, starts + s * 2);
280
+ if (cp < st) break;
281
+ var ro = u16(b, ranges + s * 2);
282
+ if (!ro) g = (cp + s16(b, deltas + s * 2)) & 0xFFFF;
283
+ else {
284
+ var gi = u16(b, ranges + s * 2 + ro + (cp - st) * 2);
285
+ g = gi ? (gi + s16(b, deltas + s * 2)) & 0xFFFF : 0;
286
+ }
287
+ break;
288
+ }
289
+ }
290
+ if (g >= font.numGlyphs) g = 0;
291
+ lookup[cp] = g;
292
+ return g;
293
+ };
294
+ font.advance = function (gid) {
295
+ var hm = font.tables.hmtx.offset;
296
+ var i2 = gid < font.numHMetrics ? gid : font.numHMetrics - 1;
297
+ return u16(b, hm + i2 * 4);
298
+ };
299
+ parsedFonts.push({ bytes: b, font: font });
300
+ if (parsedFonts.length > 4) parsedFonts.shift();
301
+ return font;
302
+ }
303
+
304
+ function glyphRange(font, gid) {
305
+ var b = font.bytes, loca = font.tables.loca.offset;
306
+ var a = font.longLoca ? u32(b, loca + gid * 4) : u16(b, loca + gid * 2) * 2;
307
+ var z = font.longLoca ? u32(b, loca + gid * 4 + 4) : u16(b, loca + gid * 2 + 2) * 2;
308
+ return [font.tables.glyf.offset + a, z - a];
309
+ }
310
+
311
+ // A font with every glyph ID kept but only the used outlines (plus the components of composite glyphs and
312
+ // .notdef); tables a CIDFontType2 needs: head, hhea, hmtx, maxp, loca, glyf, and the hinting tables if present.
313
+ function subsetTrueType(font, usedGids) {
314
+ var b = font.bytes, keep = { 0: true }, stack = [];
315
+ for (var k in usedGids) { keep[k] = true; stack.push(+k); }
316
+ while (stack.length) {
317
+ var gid = stack.pop(), r = glyphRange(font, gid);
318
+ if (r[1] < 10 || s16(b, r[0]) >= 0) continue; // empty or simple glyph
319
+ var p = r[0] + 10, more = true;
320
+ while (more) {
321
+ var flags = u16(b, p), comp = u16(b, p + 2);
322
+ if (!keep[comp]) { keep[comp] = true; stack.push(comp); }
323
+ p += 4 + ((flags & 1) ? 4 : 2) + ((flags & 8) ? 2 : (flags & 0x40) ? 4 : (flags & 0x80) ? 8 : 0);
324
+ more = !!(flags & 0x20);
325
+ }
326
+ }
327
+ var glyfParts = [], locaOut = new Uint8Array((font.numGlyphs + 1) * 4), at = 0;
328
+ function put32(arr, o, v) { arr[o] = (v >>> 24) & 255; arr[o + 1] = (v >>> 16) & 255; arr[o + 2] = (v >>> 8) & 255; arr[o + 3] = v & 255; }
329
+ for (var g = 0; g < font.numGlyphs; g++) {
330
+ put32(locaOut, g * 4, at);
331
+ if (!keep[g]) continue;
332
+ var gr = glyphRange(font, g);
333
+ if (gr[1] <= 0) continue;
334
+ var len = (gr[1] + 3) & ~3;
335
+ var part = new Uint8Array(len); part.set(b.subarray(gr[0], gr[0] + gr[1]));
336
+ glyfParts.push(part); at += len;
337
+ }
338
+ put32(locaOut, font.numGlyphs * 4, at);
339
+ var glyfOut = new Uint8Array(at), q = 0;
340
+ for (var gp = 0; gp < glyfParts.length; gp++) { glyfOut.set(glyfParts[gp], q); q += glyfParts[gp].length; }
341
+ var out = {};
342
+ ["head", "hhea", "hmtx", "maxp", "cvt ", "fpgm", "prep"].forEach(function (t) {
343
+ if (font.tables[t]) out[t] = b.slice(font.tables[t].offset, font.tables[t].offset + font.tables[t].length);
344
+ });
345
+ out.head[50] = 0; out.head[51] = 1; // indexToLocFormat: long
346
+ out.head[8] = out.head[9] = out.head[10] = out.head[11] = 0; // checkSumAdjustment, set below
347
+ out.loca = locaOut; out.glyf = glyfOut;
348
+ var tags = Object.keys(out).sort(), numT = tags.length, dirLen = 12 + numT * 16, total = dirLen;
349
+ tags.forEach(function (t) { total += (out[t].length + 3) & ~3; });
350
+ var f = new Uint8Array(total);
351
+ put32(f, 0, 0x00010000);
352
+ var es = Math.floor(Math.log(numT) / Math.LN2), sr = Math.pow(2, es) * 16;
353
+ f[4] = numT >> 8; f[5] = numT & 255; f[6] = sr >> 8; f[7] = sr & 255; f[8] = es >> 8; f[9] = es & 255;
354
+ var rs = numT * 16 - sr; f[10] = rs >> 8; f[11] = rs & 255;
355
+ function checksum(arr) { var sum = 0; for (var i = 0; i < arr.length; i += 4) sum = (sum + (((arr[i] << 24) >>> 0) + ((arr[i + 1] || 0) << 16) + ((arr[i + 2] || 0) << 8) + (arr[i + 3] || 0))) >>> 0; return sum; }
356
+ var pos = dirLen, headPos = 0;
357
+ tags.forEach(function (t, i) {
358
+ var rec = 12 + i * 16;
359
+ for (var c = 0; c < 4; c++) f[rec + c] = t.charCodeAt(c);
360
+ put32(f, rec + 4, checksum(out[t])); put32(f, rec + 8, pos); put32(f, rec + 12, out[t].length);
361
+ f.set(out[t], pos);
362
+ if (t === "head") headPos = pos;
363
+ pos += (out[t].length + 3) & ~3;
364
+ });
365
+ put32(f, headPos + 8, (0xB1B0AFBA - checksum(f)) >>> 0);
366
+ return f;
367
+ }
368
+
369
+ function hex4(n) { return ("000" + n.toString(16).toUpperCase()).slice(-4); }
370
+ // The glyph IDs of a string in the active Unicode font, as a PDF hex string; records each for the ToUnicode map.
371
+ function uniHex(str) {
372
+ var out = "";
373
+ for (var i = 0; i < str.length; i++) {
374
+ var cp = str.codePointAt(i);
375
+ if (cp > 0xFFFF) i++;
376
+ var gid = activeUni.font.gidFor(cp);
377
+ activeUni.used[gid] = cp;
378
+ out += hex4(gid);
379
+ }
380
+ return "<" + out + ">";
381
+ }
382
+ function uniWidth(str, size) {
383
+ var total = 0;
384
+ for (var i = 0; i < str.length; i++) {
385
+ var cp = str.codePointAt(i);
386
+ if (cp > 0xFFFF) i++;
387
+ total += activeUni.font.advance(activeUni.font.gidFor(cp));
388
+ }
389
+ return total * size / activeUni.font.units;
390
+ }
391
+ // Can the base fonts draw this character? (the same test toWinAnsi applies)
392
+ function winEncodable(ch) { return ch === "?" || toWinAnsi(ch).indexOf("?") < 0; }
393
+ // East Asian characters: a line may break between any two of them.
394
+ function breaksAnywhere(cp) {
395
+ return (cp >= 0x2E80 && cp <= 0x9FFF) || (cp >= 0xAC00 && cp <= 0xD7AF) || (cp >= 0xF900 && cp <= 0xFAFF) ||
396
+ (cp >= 0xFF00 && cp <= 0xFFEF) || (cp >= 0x3000 && cp <= 0x303F) || (cp >= 0x20000 && cp <= 0x2FA1F);
397
+ }
398
+ // Splits a token into pieces drawn by the base fonts ({ text }) and by the Unicode font ({ text, uni: true }),
399
+ // with every East Asian character its own piece so a line can break there.
400
+ function uniPieces(tok) {
401
+ var pieces = [], cur = null;
402
+ for (var i = 0; i < tok.length; i++) {
403
+ var cp = tok.codePointAt(i), ch = String.fromCodePoint(cp);
404
+ if (cp > 0xFFFF) i++;
405
+ var uni = !winEncodable(ch) && activeUni.font.gidFor(cp) !== 0;
406
+ if (uni && breaksAnywhere(cp)) { cur = null; pieces.push({ text: ch, uni: true }); continue; }
407
+ if (!cur || cur.uni !== uni) { cur = { text: "", uni: uni }; pieces.push(cur); }
408
+ cur.text += ch;
409
+ }
410
+ return pieces;
411
+ }
412
+
413
+ // ------------------------------------------------------------ PDF string
414
+ //
415
+ // WinAnsiEncoding covers Latin-1 plus the printable range of CP1252. A
416
+ // character outside it has no glyph in a base-14 font, so it is transliterated
417
+ // rather than written as a byte the viewer would render as garbage.
418
+ var WINANSI_EXTRA = {
419
+ 0x20AC: 128, 0x201A: 130, 0x0192: 131, 0x201E: 132, 0x2026: 133, 0x2020: 134,
420
+ 0x2021: 135, 0x02C6: 136, 0x2030: 137, 0x0160: 138, 0x2039: 139, 0x0152: 140,
421
+ 0x017D: 142, 0x2018: 145, 0x2019: 146, 0x201C: 147, 0x201D: 148, 0x2022: 149,
422
+ 0x2013: 150, 0x2014: 151, 0x02DC: 152, 0x2122: 153, 0x0161: 154, 0x203A: 155,
423
+ 0x0153: 156, 0x017E: 158, 0x0178: 159
424
+ };
425
+ function toWinAnsi(str) {
426
+ var out = "";
427
+ for (var i = 0; i < str.length; i++) {
428
+ var cp = str.charCodeAt(i);
429
+ if (cp < 256) { out += String.fromCharCode(cp); continue; }
430
+ var mapped = WINANSI_EXTRA[cp];
431
+ if (mapped) { out += String.fromCharCode(mapped); continue; }
432
+ // Common shapes that would otherwise become "?" for no good reason.
433
+ if (cp === 0x00A0) { out += " "; continue; }
434
+ if (cp >= 0x2000 && cp <= 0x200A) { out += " "; continue; }
435
+ if (cp === 0x2212) { out += "-"; continue; }
436
+ // 2026-10-26 invisible format characters (zero-width space / joiners, word joiner, BOM) have no glyph to
437
+ // draw: drop them instead of printing "?" in the middle of Latin text.
438
+ if ((cp >= 0x200B && cp <= 0x200F) || cp === 0x2060 || cp === 0xFEFF) continue;
439
+ out += "?";
440
+ }
441
+ return out;
442
+ }
443
+ // The characters (each once, in document order) that toWinAnsi would write as "?": what a vector export of the
444
+ // serialized document, its title and its running header / footer would lose.
445
+ function unsupportedText(options, ignoreFont) {
446
+ var text = "";
447
+ try {
448
+ var box = document.createElement("div");
449
+ box.innerHTML = __RTE_TT(editor.getHTMLCode() || "");
450
+ text = box.textContent || "";
451
+ } catch (e) { text = ""; }
452
+ text += " " + (options.title || config.pdfExportTitle || "") + " " + (options.headerText || config.pdfExportHeader || "") +
453
+ " " + (options.footerText || config.pdfExportFooter || "");
454
+ var seen = {}, out = "";
455
+ var uniFont = ignoreFont ? null : uniFontFrom(options);
456
+ for (var i = 0; i < text.length; i++) {
457
+ var ch = text.charAt(i), code = text.charCodeAt(i);
458
+ if (code >= 0xD800 && code <= 0xDBFF && i + 1 < text.length) { ch = text.substr(i, 2); i++; }
459
+ if (seen[ch] || ch === "?") continue;
460
+ if (toWinAnsi(ch).indexOf("?") >= 0 && !(uniFont && uniFont.gidFor(ch.codePointAt(0)))) { seen[ch] = 1; out += ch; }
461
+ }
462
+ return out;
463
+ }
464
+ // 2026-10-26 A PDF text string (title, author, bookmark, alt text, link or comment description) is not drawn with
465
+ // a font, so it can carry any script as UTF-16BE: <FEFF...>. These went through WinAnsi like the page text and a
466
+ // Chinese title, Russian bookmark or Japanese alt text - what a screen reader announces - became "???". Strings
467
+ // WinAnsi can carry keep the plain (...) form. tools/pdf/pdf-unicode-strings.mjs
468
+ function pdfText(str) {
469
+ var t = String(str == null ? "" : str);
470
+ var win = toWinAnsi(t);
471
+ if (win.indexOf("?") < 0 || win.split("?").length === t.split("?").length) return "(" + escapeString(win) + ")";
472
+ var hex = "<FEFF";
473
+ for (var i = 0; i < t.length; i++) hex += ("000" + t.charCodeAt(i).toString(16).toUpperCase()).slice(-4);
474
+ return hex + ">";
475
+ }
476
+ // A URI is ASCII: percent-encode anything else (it became "?").
477
+ function pdfUri(href) {
478
+ return String(href || "").replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]|[^\x00-\x7F]/g, function (c) {
479
+ try { return encodeURIComponent(c); } catch (e) { return ""; }
480
+ });
481
+ }
482
+ function escapeString(str) {
483
+ return str.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
484
+ }
485
+
486
+ // ------------------------------------------------------------- PDF writer
487
+ function PdfWriter() {
488
+ this.objects = []; // 1-based; objects[i] is the body of object i+1
489
+ }
490
+ PdfWriter.prototype.alloc = function () { this.objects.push(null); return this.objects.length; };
491
+ PdfWriter.prototype.set = function (id, body) { this.objects[id - 1] = body; };
492
+ PdfWriter.prototype.add = function (body) { var id = this.alloc(); this.set(id, body); return id; };
493
+
494
+ // Assembled as BYTES, not as a string: a content stream can hold binary
495
+ // (an embedded JPEG), and /Length plus the xref offsets are byte counts. A
496
+ // string-based assembler is correct right up until the first image, then
497
+ // silently produces a file no reader will open.
498
+ PdfWriter.prototype.build = function (rootId, infoId) {
499
+ var chunks = [], length = 0;
500
+ function push(part) {
501
+ var bytes = (typeof part === "string") ? latin1Bytes(part) : part;
502
+ chunks.push(bytes); length += bytes.length;
503
+ return length;
504
+ }
505
+ push("%PDF-1.4\n");
506
+ // A binary comment marks the file as binary for transfer agents.
507
+ push(new Uint8Array([0x25, 0xE2, 0xE3, 0xCF, 0xD3, 0x0A]));
508
+
509
+ var offsets = [];
510
+ for (var i = 0; i < this.objects.length; i++) {
511
+ offsets[i] = length;
512
+ push((i + 1) + " 0 obj\n");
513
+ var body = this.objects[i];
514
+ if (body && body.stream) {
515
+ var data = (typeof body.stream === "string") ? latin1Bytes(body.stream) : body.stream;
516
+ push(body.dict.replace("/Length 0", "/Length " + data.length) + "\nstream\n");
517
+ push(data);
518
+ push("\nendstream\n");
519
+ } else {
520
+ push(body + "\n");
521
+ }
522
+ push("endobj\n");
523
+ }
524
+ var xrefAt = length;
525
+ var xref = "xref\n0 " + (this.objects.length + 1) + "\n0000000000 65535 f \n";
526
+ for (var j = 0; j < offsets.length; j++) {
527
+ xref += pad10(offsets[j]) + " 00000 n \n";
528
+ }
529
+ push(xref);
530
+ push("trailer\n<< /Size " + (this.objects.length + 1) + " /Root " + rootId + " 0 R /Info " + infoId + " 0 R >>\n");
531
+ push("startxref\n" + xrefAt + "\n%%EOF\n");
532
+
533
+ var out = new Uint8Array(length), at = 0;
534
+ for (var k = 0; k < chunks.length; k++) { out.set(chunks[k], at); at += chunks[k].length; }
535
+ return out;
536
+ };
537
+ function pad10(n) { var s = String(n); while (s.length < 10) s = "0" + s; return s; }
538
+ function latin1Bytes(str) {
539
+ var out = new Uint8Array(str.length);
540
+ for (var i = 0; i < str.length; i++) out[i] = str.charCodeAt(i) & 0xFF;
541
+ return out;
542
+ }
543
+
544
+ // ---------------------------------------------------------------- layout
545
+ //
546
+ // A flat list of positioned draw operations is produced first, then paged.
547
+ // Keeping measurement and pagination apart is what makes "keep this heading
548
+ // with the paragraph under it" and "repeat a table header row" possible at
549
+ // all; doing both in one pass forces every decision to be final immediately.
550
+
551
+ function styleOf(node, inherited) {
552
+ var s = {
553
+ bold: inherited.bold, italic: inherited.italic, underline: inherited.underline,
554
+ strike: inherited.strike, size: inherited.size, color: inherited.color,
555
+ mono: inherited.mono, link: inherited.link, lang: inherited.lang, comment: inherited.comment || null
556
+ };
557
+ // 2026-10-17 a run's language: the nearest lang attribute. tools/pdf/inline-lang.mjs
558
+ var nodeLang = node.getAttribute ? node.getAttribute("lang") : "";
559
+ if (nodeLang) s.lang = nodeLang;
560
+ var tag = node.tagName ? node.tagName.toLowerCase() : "";
561
+ if (tag === "b" || tag === "strong") s.bold = true;
562
+ if (tag === "i" || tag === "em") s.italic = true;
563
+ if (tag === "u" || tag === "ins") s.underline = true;
564
+ if (tag === "s" || tag === "strike" || tag === "del") s.strike = true;
565
+ if (tag === "code" || tag === "kbd" || tag === "samp" || tag === "pre") s.mono = true;
566
+ if (tag === "a" && node.getAttribute("href")) { s.link = node.getAttribute("href"); s.color = [0, 0.33, 0.8]; s.underline = true; }
567
+ if (node.getAttribute && node.getAttribute("data-comment")) s.comment = { text: node.getAttribute("data-comment"), author: node.getAttribute("data-comment-author") || "" };
568
+ if (tag === "sup" || tag === "sub") s.size = s.size * 0.75;
569
+
570
+ var css = node.style;
571
+ if (css) {
572
+ if (/bold|^[6-9]00$/.test(css.fontWeight || "")) s.bold = true;
573
+ if (css.fontStyle === "italic" || css.fontStyle === "oblique") s.italic = true;
574
+ if ((css.textDecorationLine || css.textDecoration || "").indexOf("underline") >= 0) s.underline = true;
575
+ if ((css.textDecorationLine || css.textDecoration || "").indexOf("line-through") >= 0) s.strike = true;
576
+ if (css.color) { var c = parseColor(css.color); if (c) s.color = c; }
577
+ if (css.fontSize) { var fs = toPoints(css.fontSize, null); if (fs) s.size = fs; }
578
+ }
579
+ return s;
580
+ }
581
+
582
+ function parseColor(value) {
583
+ var m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.replace(/\s/g, ""));
584
+ if (m) {
585
+ var hex = m[1];
586
+ if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
587
+ return [parseInt(hex.substr(0, 2), 16) / 255, parseInt(hex.substr(2, 2), 16) / 255, parseInt(hex.substr(4, 2), 16) / 255];
588
+ }
589
+ var rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(value);
590
+ if (rgb) return [rgb[1] / 255, rgb[2] / 255, rgb[3] / 255];
591
+ return null;
592
+ }
593
+
594
+ var HEADING_SCALE = { h1: 2, h2: 1.6, h3: 1.35, h4: 1.15, h5: 1, h6: 0.9 };
595
+
596
+ function Layout(geom, baseSize, familyKey) {
597
+ this.geom = geom;
598
+ this.baseSize = baseSize;
599
+ this.family = familyKey;
600
+ this.contentWidth = geom.width - geom.margin.left - geom.margin.right;
601
+ this.blocks = []; // { type, ... } in document order
602
+ }
603
+
604
+ // Collect the inline runs of a block, splitting into words for wrapping.
605
+ Layout.prototype.runsOf = function (node, inherited) {
606
+ var runs = [];
607
+ var self = this;
608
+ (function walk(n, style) {
609
+ for (var i = 0; i < n.childNodes.length; i++) {
610
+ var c = n.childNodes[i];
611
+ if (c.nodeType === 3) {
612
+ var text = (c.nodeValue || "").replace(/\s+/g, " ");
613
+ if (text) runs.push({ text: text, style: style });
614
+ continue;
615
+ }
616
+ if (c.nodeType !== 1) continue;
617
+ var tag = c.tagName.toLowerCase();
618
+ if (tag === "br") { runs.push({ br: true, style: style }); continue; }
619
+ if (tag === "img") { runs.push({ img: c, style: style }); continue; }
620
+ if (tag === "script" || tag === "style") continue;
621
+ walk(c, styleOf(c, style));
622
+ }
623
+ })(node, inherited);
624
+ return runs;
625
+ };
626
+
627
+ // Break runs into laid-out lines that fit `width`.
628
+ Layout.prototype.wrap = function (runs, width, lineHeightFactor) {
629
+ var lines = [], line = [], used = 0, self = this;
630
+ var maxSize = this.baseSize;
631
+
632
+ function flush() {
633
+ // Trailing spaces must not count toward a line's width, or a
634
+ // right-aligned or centred line drifts by the width of the space.
635
+ while (line.length && /^\s+$/.test(line[line.length - 1].text || "")) line.pop();
636
+ lines.push({ parts: line, width: used, size: maxSize });
637
+ line = []; used = 0; maxSize = self.baseSize;
638
+ }
639
+
640
+ for (var r = 0; r < runs.length; r++) {
641
+ var run = runs[r];
642
+ if (run.br) { flush(); continue; }
643
+ if (run.img) {
644
+ var dims = imageDims(run.img, width);
645
+ if (line.length) flush();
646
+ lines.push({ parts: [{ img: run.img, w: dims.w, h: dims.h, style: run.style }], width: dims.w, size: dims.h, image: true });
647
+ continue;
648
+ }
649
+ var styleIndex = (run.style.bold ? 1 : 0) | (run.style.italic ? 2 : 0);
650
+ var family = run.style.mono ? "courier" : this.family;
651
+ // Split keeping the separators, so a space that ends a line can be
652
+ // dropped while a space between two words on the same line is kept.
653
+ var tokens = run.text.split(/(\s+)/);
654
+ for (var t = 0; t < tokens.length; t++) {
655
+ var whole = tokens[t];
656
+ if (!whole) continue;
657
+ var pieces = activeUni && !/^\s+$/.test(whole) ? uniPieces(whole) : [{ text: whole, uni: false }];
658
+ for (var pc = 0; pc < pieces.length; pc++) {
659
+ var tok = pieces[pc].text, isUni = pieces[pc].uni;
660
+ var w = isUni ? uniWidth(tok, run.style.size) : textWidth(toWinAnsi(tok), family, styleIndex, run.style.size);
661
+ // a piece that continues a word (no space before it) may still wrap: CJK breaks anywhere
662
+ if (used + w > width && line.length && !/^\s+$/.test(tok) && (pc === 0 || isUni)) {
663
+ flush();
664
+ // A wrapped line never starts with the space that caused it.
665
+ if (/^\s+$/.test(tok)) continue;
666
+ }
667
+ line.push({ text: tok, style: run.style, family: family, styleIndex: styleIndex, w: w, uni: isUni });
668
+ used += w;
669
+ if (run.style.size > maxSize) maxSize = run.style.size;
670
+ }
671
+ }
672
+ }
673
+ if (line.length) flush();
674
+ if (!lines.length) lines.push({ parts: [], width: 0, size: this.baseSize });
675
+ for (var i = 0; i < lines.length; i++) lines[i].height = lines[i].image ? lines[i].size : lines[i].size * lineHeightFactor;
676
+ return lines;
677
+ };
678
+
679
+ function imageDims(img, maxWidth) {
680
+ var w = img.naturalWidth || img.width || 0;
681
+ var h = img.naturalHeight || img.height || 0;
682
+ if (!w || !h) return { w: 0, h: 0 };
683
+ var pw = w * 0.75, ph = h * 0.75; // CSS px -> points
684
+ if (pw > maxWidth) { ph = ph * maxWidth / pw; pw = maxWidth; }
685
+ return { w: pw, h: ph };
686
+ }
687
+
688
+ // ------------------------------------------------------------ block walk
689
+ Layout.prototype.run = function (root) {
690
+ var self = this;
691
+ var base = { bold: false, italic: false, underline: false, strike: false, size: this.baseSize, color: [0, 0, 0], mono: false, link: null };
692
+
693
+ // A block indented in the editor carries margin-left (or padding-left, per
694
+ // indentUseMargin). Before 2026-09-04 the PDF honoured an indent only when
695
+ // it was spelled <blockquote>, because that was the only way the editor
696
+ // could express one. Points, since that is the PDF's unit: 1px = 0.75pt.
697
+ function cssIndentPt(node) {
698
+ if (!node || !node.style) return 0;
699
+ var raw = node.style.marginLeft || node.style.paddingLeft || "";
700
+ var m = /^\s*(-?[\d.]+)\s*([a-z%]*)\s*$/i.exec(raw);
701
+ if (!m) return 0;
702
+ var n = parseFloat(m[1]);
703
+ if (!n || n <= 0) return 0;
704
+ var unit = (m[2] || "px").toLowerCase();
705
+ var px = unit === "px" ? n
706
+ : unit === "pt" ? n * (4 / 3)
707
+ : unit === "in" ? n * 96
708
+ : unit === "cm" ? n * 37.795
709
+ : unit === "mm" ? n * 3.7795
710
+ : (unit === "em" || unit === "rem") ? n * 16
711
+ : 0;
712
+ return px * 0.75;
713
+ }
714
+
715
+ // The language a block is written in: its own lang, the nearest ancestor's inside the content, or - when its
716
+ // whole text is one <span lang> - that span's. "" when none is set.
717
+ function blockLang(node) {
718
+ for (var n = node; n && n.nodeType === 1 && n.tagName.toLowerCase() !== "body"; n = n.parentNode) {
719
+ if (n.getAttribute("lang")) return n.getAttribute("lang");
720
+ }
721
+ var el = null, k = node.childNodes;
722
+ for (var i = 0; i < k.length; i++) {
723
+ if (k[i].nodeType === 3 && k[i].nodeValue.replace(/[\s\u200b\u00a0]/g, "")) return "";
724
+ if (k[i].nodeType === 1) { if (el) return ""; el = k[i]; }
725
+ }
726
+ return el && el.getAttribute("lang") ? el.getAttribute("lang") : "";
727
+ }
728
+ function block(node, style, indent, listMarker, listId, listOrdered, roleOverride) {
729
+ var tag = node.tagName.toLowerCase();
730
+ var s = styleOf(node, style);
731
+ var align = "left";
732
+ var ta = (node.style && node.style.textAlign) || "";
733
+ if (ta === "center" || ta === "right" || ta === "justify") align = ta;
734
+
735
+ if (HEADING_SCALE[tag]) { s.size = self.baseSize * HEADING_SCALE[tag]; s.bold = true; }
736
+ if (tag === "pre") s.mono = true;
737
+
738
+ var width = self.contentWidth - indent;
739
+ var runs = self.runsOf(node, s);
740
+ var lines = self.wrap(runs, width, tag === "pre" ? 1.25 : 1.4);
741
+ self.blocks.push({
742
+ type: "text", lines: lines, indent: indent, align: align,
743
+ // 2026-10-15 the block's own language, for /Lang on its structure element. tools/pdf/paragraph-lang.mjs
744
+ lang: blockLang(node),
745
+ spaceBefore: HEADING_SCALE[tag] ? s.size * 0.6 : self.baseSize * 0.35,
746
+ spaceAfter: HEADING_SCALE[tag] ? s.size * 0.3 : self.baseSize * 0.35,
747
+ marker: listMarker || null, markerStyle: s,
748
+ // A heading alone at the foot of a page is the classic ugly
749
+ // break; it is pinned to what follows it.
750
+ keepWithNext: !!HEADING_SCALE[tag],
751
+ rule: tag === "blockquote" ? "quote" : null,
752
+ // Semantics for the tag tree. A PDF whose text is selectable but
753
+ // untagged still fails an accessibility audit: assistive
754
+ // technology gets a flat stream with no headings, no list
755
+ // structure and no reliable reading order.
756
+ // roleOverride carries the quotation down to the paragraphs INSIDE a
757
+ // <blockquote>. The role was only ever emitted for a blockquote
758
+ // holding inline text directly - and the editor writes
759
+ // <blockquote><p>...</p></blockquote>, so in practice every
760
+ // blockquote we produce was tagged /P and the quotation was absent
761
+ // from the accessibility tree we publish claims about.
762
+ role: roleOverride || (HEADING_SCALE[tag] ? "H" + tag.charAt(1) : (tag === "blockquote" ? "BlockQuote" : "P")),
763
+ listId: listId || null, listOrdered: !!listOrdered
764
+ });
765
+ }
766
+
767
+ function walk(node, style, indent, roleOverride) {
768
+ for (var i = 0; i < node.childNodes.length; i++) {
769
+ var c = node.childNodes[i];
770
+ if (c.nodeType === 3) {
771
+ if (!(c.nodeValue || "").trim()) continue;
772
+ // Loose text directly under the root still deserves a block.
773
+ self.blocks.push({ type: "text", lines: self.wrap([{ text: c.nodeValue.replace(/\s+/g, " "), style: style }], self.contentWidth - indent, 1.4), indent: indent, align: "left", spaceBefore: 0, spaceAfter: self.baseSize * 0.35 });
774
+ continue;
775
+ }
776
+ if (c.nodeType !== 1) continue;
777
+ var tag = c.tagName.toLowerCase();
778
+ if (tag === "script" || tag === "style" || tag === "noscript") continue;
779
+
780
+ if (c.getAttribute && c.getAttribute("data-rte-page-break") !== null) { self.blocks.push({ type: "pagebreak" }); continue; }
781
+ if (c.style && /always|page/.test(c.style.pageBreakBefore || c.style.breakBefore || "")) self.blocks.push({ type: "pagebreak" });
782
+
783
+ if (tag === "hr") { self.blocks.push({ type: "rule", indent: indent }); continue; }
784
+ if (tag === "ul" || tag === "ol") {
785
+ var counter = parseInt(c.getAttribute("start"), 10);
786
+ if (isNaN(counter)) counter = 1;
787
+ // 2026-10-04 a lettered / Roman list is numbered the way the editor shows it (it was
788
+ // always 1. 2. 3.). tools/pdf/list-markers.mjs
789
+ var lst = String((c.style && c.style.listStyleType) || "").toLowerCase(), typ = c.getAttribute("type") || "";
790
+ var kind = /^(lower-alpha|lower-latin)$/.test(lst) || typ === "a" ? "a"
791
+ : /^(upper-alpha|upper-latin)$/.test(lst) || typ === "A" ? "A"
792
+ : lst === "lower-roman" || typ === "i" ? "i"
793
+ : lst === "upper-roman" || typ === "I" ? "I" : "1";
794
+ // Every item of one list shares an id so the tag tree can
795
+ // rebuild a single <L> around them; without it each item
796
+ // becomes an isolated paragraph and a screen reader never
797
+ // announces "list, 4 items".
798
+ var listId = "L" + (self.listSeq = (self.listSeq || 0) + 1);
799
+ for (var li = 0; li < c.childNodes.length; li++) {
800
+ var item = c.childNodes[li];
801
+ if (item.nodeType !== 1 || item.tagName.toLowerCase() !== "li") continue;
802
+ var marker = tag === "ol" ? listNumber(counter++, kind) + "." : "\u2022";
803
+ // 2026-10-15 a task item shows whether it is done: every item got the plain bullet, so a printed
804
+ // checklist lost every tick. The standard fonts have no ballot box, so "[x]" / "[ ]".
805
+ // tools/pdf/task-list-marks.mjs
806
+ if (c.classList && c.classList.contains("rte-task-list")) marker = item.getAttribute("data-checked") === "true" ? "[x]" : "[ ]";
807
+ // The item's own text, then any nested list one level in.
808
+ block(item, styleOf(item, style), indent + self.baseSize * 1.6, marker, listId, tag === "ol");
809
+ walkNestedLists(item, styleOf(item, style), indent + self.baseSize * 1.6);
810
+ }
811
+ continue;
812
+ }
813
+ if (tag === "table") { self.table(c, styleOf(c, style), indent); continue; }
814
+ if (tag === "img") {
815
+ var dims = imageDims(c, self.contentWidth - indent);
816
+ if (dims.w) self.blocks.push({ type: "text", role: "Figure", alt: c.getAttribute("alt") || "", lines: [{ parts: [{ img: c, w: dims.w, h: dims.h, style: style }], width: dims.w, size: dims.h, height: dims.h, image: true }], indent: indent, align: "left", spaceBefore: 4, spaceAfter: 4 });
817
+ continue;
818
+ }
819
+ if (isBlockTag(tag)) {
820
+ var own = (tag === "blockquote" ? self.baseSize * 1.5 : 0) + cssIndentPt(c);
821
+ var childRole = (tag === "blockquote") ? "BlockQuote" : roleOverride;
822
+ // 2026-10-15 a block holding just an image - the shape the editor gives every inserted picture,
823
+ // <p><img></p> - is a Figure with its alt text. Only an <img> sitting directly at block level was;
824
+ // inside a <p> it was an inline run, so the PDF had no Figure and no /Alt (WCAG 1.1.1, PDF/UA).
825
+ // tools/pdf/figure-alt.mjs
826
+ var soleImgs = c.getElementsByTagName("img");
827
+ if (soleImgs.length === 1 && !(c.textContent || "").replace(/[\s\u200b\u00a0]/g, "") && !hasBlockChildren(c)) {
828
+ var fdims = imageDims(soleImgs[0], self.contentWidth - indent - own);
829
+ if (fdims.w) {
830
+ var falign = String((c.style && c.style.textAlign) || "").toLowerCase();
831
+ self.blocks.push({ type: "text", role: "Figure", alt: soleImgs[0].getAttribute("alt") || "", lines: [{ parts: [{ img: soleImgs[0], w: fdims.w, h: fdims.h, style: style }], width: fdims.w, size: fdims.h, height: fdims.h, image: true }], indent: indent + own, align: falign === "center" || falign === "right" ? falign : "left", spaceBefore: 4, spaceAfter: 4 });
832
+ continue;
833
+ }
834
+ }
835
+ if (hasBlockChildren(c)) { walk(c, styleOf(c, style), indent + own, childRole); continue; }
836
+ block(c, style, indent + own, null, null, false, childRole);
837
+ continue;
838
+ }
839
+ // Inline content sitting directly under a container.
840
+ block(c, style, indent, null);
841
+ }
842
+ }
843
+
844
+ function walkNestedLists(item, style, indent) {
845
+ for (var i = 0; i < item.childNodes.length; i++) {
846
+ var c = item.childNodes[i];
847
+ if (c.nodeType !== 1) continue;
848
+ var tag = c.tagName.toLowerCase();
849
+ if (tag === "ul" || tag === "ol") walk({ childNodes: [c] }, style, indent);
850
+ }
851
+ }
852
+
853
+ walk(root, base, 0);
854
+ return this.blocks;
855
+ };
856
+
857
+ function isBlockTag(tag) {
858
+ return /^(p|div|h[1-6]|blockquote|pre|section|article|header|footer|main|aside|figure|figcaption|dl|dd|dt|li|address)$/.test(tag);
859
+ }
860
+ function hasBlockChildren(node) {
861
+ for (var i = 0; i < node.childNodes.length; i++) {
862
+ var c = node.childNodes[i];
863
+ if (c.nodeType === 1 && (isBlockTag(c.tagName.toLowerCase()) || /^(ul|ol|table|hr)$/.test(c.tagName.toLowerCase()))) return true;
864
+ }
865
+ return false;
866
+ }
867
+
868
+ Layout.prototype.table = function (node, style, indent) {
869
+ var self = this;
870
+ var rows = [];
871
+ var trs = node.getElementsByTagName("tr");
872
+ var maxCells = 0;
873
+ // 2026-10-15 rowspan: covered[col] = rows a cell above still covers there. Cells in the rows below skip those
874
+ // columns (they were placed from column 0, shifting a row left under the wrong header), and each covered
875
+ // position becomes an empty placeholder cell so the grid stays aligned. tools/pdf/table-rowspan.mjs
876
+ var covered = {};
877
+ for (var r = 0; r < trs.length; r++) {
878
+ var cells = [];
879
+ for (var c = 0; c < trs[r].childNodes.length; c++) {
880
+ var cell = trs[r].childNodes[c];
881
+ if (cell.nodeType !== 1) continue;
882
+ var tag = cell.tagName.toLowerCase();
883
+ if (tag !== "td" && tag !== "th") continue;
884
+ // 2026-10-15 colspan: a cell starts at its grid column and spans its columns. tools/pdf/table-colspan.mjs
885
+ var span = Math.max(1, Math.min(50, parseInt(cell.getAttribute("colspan"), 10) || 1));
886
+ var rspan = Math.max(1, Math.min(500, parseInt(cell.getAttribute("rowspan"), 10) || 1));
887
+ cells.push({ node: cell, header: tag === "th", span: span, rowspan: rspan });
888
+ }
889
+ var placed = [], gridCols = 0;
890
+ for (var gc = 0; gc < cells.length; gc++) {
891
+ while (covered[gridCols] > 0) { placed.push({ placeholder: true, start: gridCols, span: 1, header: false }); gridCols++; }
892
+ cells[gc].start = gridCols;
893
+ placed.push(cells[gc]);
894
+ if (cells[gc].rowspan > 1) for (var rc = gridCols; rc < gridCols + cells[gc].span; rc++) covered[rc] = cells[gc].rowspan;
895
+ gridCols += cells[gc].span;
896
+ }
897
+ // covered columns after the row's last cell
898
+ for (var tail in covered) if (+tail >= gridCols && covered[tail] > 0) { placed.push({ placeholder: true, start: +tail, span: 1, header: false }); if (+tail + 1 > gridCols) gridCols = +tail + 1; }
899
+ // this row is done: each covering cell has one row fewer left (a cell covering from THIS row still needs
900
+ // its own count, so it was set to rowspan and drops to rowspan - 1 here)
901
+ for (var dc in covered) { covered[dc]--; if (covered[dc] <= 0) delete covered[dc]; }
902
+ placed.sort(function (p1, p2) { return p1.start - p2.start; });
903
+ cells = cells.length ? placed : cells;
904
+ if (cells.length) { rows.push(cells); if (gridCols > maxCells) maxCells = gridCols; }
905
+ }
906
+ if (!rows.length) return;
907
+
908
+ var width = this.contentWidth - indent;
909
+ var colWidth = width / maxCells;
910
+ // 2026-10-15 column widths follow the <colgroup> (% or px), scaled to the table's width on the page: every
911
+ // table was split into equal columns. A table without one keeps equal columns. tools/pdf/table-col-widths.mjs
912
+ var colWidths = [], cg = [], cw = 0;
913
+ for (var ck = 0; ck < node.children.length; ck++) if (node.children[ck].tagName && node.children[ck].tagName.toLowerCase() === "colgroup") {
914
+ for (var cj = 0; cj < node.children[ck].children.length; cj++) if (node.children[ck].children[cj].tagName.toLowerCase() === "col") cg.push(node.children[ck].children[cj]);
915
+ }
916
+ if (cg.length === maxCells) {
917
+ for (var cq = 0; cq < cg.length; cq++) {
918
+ var cv = (cg[cq].style && cg[cq].style.width) || cg[cq].getAttribute("width") || "", cm = /^\s*([\d.]+)(%|px)?\s*$/.exec(cv);
919
+ if (!cm || !(parseFloat(cm[1]) > 0)) { colWidths = []; break; }
920
+ colWidths.push(parseFloat(cm[1]));
921
+ }
922
+ for (var cs2 = 0; cs2 < colWidths.length; cs2++) cw += colWidths[cs2];
923
+ if (cw > 0) colWidths = colWidths.map(function (v) { return v / cw * width; });
924
+ }
925
+ if (colWidths.length !== maxCells) { colWidths = []; for (var ce = 0; ce < maxCells; ce++) colWidths.push(colWidth); }
926
+ function spanWidth(start, span) { var s = 0; for (var sw = start; sw < start + span && sw < colWidths.length; sw++) s += colWidths[sw]; return s; }
927
+ var pad = 4;
928
+ var laid = [];
929
+ for (var i = 0; i < rows.length; i++) {
930
+ var out = [], tallest = 0;
931
+ for (var j = 0; j < rows[i].length; j++) {
932
+ if (rows[i][j].placeholder) { out.push({ lines: [], header: false, start: rows[i][j].start, span: 1, placeholder: true }); continue; }
933
+ var cs = styleOf(rows[i][j].node, style);
934
+ if (rows[i][j].header) cs.bold = true;
935
+ var lines = self.wrap(self.runsOf(rows[i][j].node, cs), spanWidth(rows[i][j].start, rows[i][j].span) - pad * 2, 1.35);
936
+ var h = 0;
937
+ for (var k = 0; k < lines.length; k++) h += lines[k].height;
938
+ if (h > tallest) tallest = h;
939
+ out.push({ lines: lines, header: rows[i][j].header, start: rows[i][j].start, span: rows[i][j].span, rowspan: rows[i][j].rowspan || 1 });
940
+ }
941
+ laid.push({ cells: out, height: tallest + pad * 2, header: rows[i][0] && !rows[i][0].placeholder && rows[i][0].header });
942
+ }
943
+ this.blocks.push({
944
+ type: "table", rows: laid, colWidth: colWidth, colWidths: colWidths, cols: maxCells,
945
+ indent: indent, pad: pad,
946
+ spaceBefore: this.baseSize * 0.5, spaceAfter: this.baseSize * 0.5
947
+ });
948
+ };
949
+
950
+ // ------------------------------------------------------------- rendering
951
+ // 2026-10-15 the document language, read by the tagger to mark blocks in other languages
952
+ var pdfDocLang = "en";
953
+ // Marks each live comment's text in the copy as a data-comment span (see buildPdf).
954
+ function markCommentsForPdf(host) {
955
+ var live = editor.getEditable().querySelectorAll("[data-comment-id]");
956
+ if (!live.length || !editor.reviewLedger) return;
957
+ var ledger = {};
958
+ editor.reviewLedger.list().forEach(function (e) { if (e.changeType === "comment") ledger[e.id] = e; });
959
+ var done = {};
960
+ for (var i = 0; i < live.length; i++) {
961
+ var id = live[i].getAttribute("data-comment-id"), entry = ledger[id], phrase = live[i].textContent || "";
962
+ if (!entry || !phrase || done[id]) continue;
963
+ done[id] = true;
964
+ var w = document.createTreeWalker(host, NodeFilter.SHOW_TEXT, null, false), tn, at = -1;
965
+ while ((tn = w.nextNode())) { at = tn.data.indexOf(phrase); if (at !== -1) break; }
966
+ if (at === -1) continue;
967
+ var rng = document.createRange();
968
+ rng.setStart(tn, at); rng.setEnd(tn, at + phrase.length);
969
+ var name = (entry.author && entry.author.name) || "RichTextEditor user";
970
+ var mark = document.createElement("span");
971
+ mark.setAttribute("data-comment", entry.text || "");
972
+ mark.setAttribute("data-comment-author", name);
973
+ try { rng.surroundContents(mark); } catch (er) { /* no mark */ }
974
+ }
975
+ }
976
+ function uniFontFrom(options) {
977
+ var src = (options && options.unicodeFont) || config.pdfExportUnicodeFont;
978
+ if (!src) return null;
979
+ try { return parseTrueType(src); }
980
+ catch (e) { if (window.console && console.warn) console.warn(e.message); return null; }
981
+ }
982
+ function buildPdf(options) {
983
+ activeUni = null;
984
+ var uniFont = uniFontFrom(options);
985
+ if (uniFont && unsupportedText(options, true)) activeUni = { font: uniFont, used: {} };
986
+ try { return buildPdfWith(options); } finally { activeUni = null; }
987
+ }
988
+ function buildPdfWith(options) {
989
+ pdfDocLang = String((options && options.lang) || document.documentElement.lang || "en").toLowerCase();
990
+ var familyKey = String(options.font || config.pdfExportFont || "helvetica").toLowerCase();
991
+ if (!FONT_FAMILIES[familyKey]) familyKey = "helvetica";
992
+ var baseSize = Number(options.fontSize || config.pdfExportBaseFontSize || 11);
993
+ if (!isFinite(baseSize) || baseSize <= 0) baseSize = 11;
994
+ var geom = pageGeometry(options);
995
+
996
+ // getHTMLCode() is the SERIALIZED document: presentational plugins strip
997
+ // their own chrome from it. Reading the live DOM instead would bake page
998
+ // overlays, watermarks and formatting marks into the PDF.
999
+ var host = document.createElement("div");
1000
+ host.innerHTML = __RTE_TT(editor.getHTMLCode() || "");
1001
+ var liveLists = editor.getEditable().querySelectorAll("ol,ul"), exportLists = host.querySelectorAll("ol,ul");
1002
+ if (liveLists.length === exportLists.length) for (var li = 0; li < liveLists.length; li++) {
1003
+ var listStyle = liveLists[li].ownerDocument.defaultView.getComputedStyle(liveLists[li]).listStyleType;
1004
+ if (listStyle) exportLists[li].style.listStyleType = listStyle;
1005
+ }
1006
+
1007
+ // 2026-10-21 comments reach the PDF as /Text notes: their anchors and text are marked in the copy first,
1008
+ // as in the Word export. tools/pdf/comments.mjs
1009
+ try { markCommentsForPdf(host); } catch (cmErr) { /* the PDF without notes */ }
1010
+ var layout = new Layout(geom, baseSize, familyKey);
1011
+ var blocks = layout.run(host);
1012
+
1013
+ var pages = paginate(blocks, geom, baseSize);
1014
+ return emit(pages, geom, familyKey, baseSize, options);
1015
+ }
1016
+
1017
+ function paginate(blocks, geom, baseSize) {
1018
+ var usableTop = geom.height - geom.margin.top;
1019
+ var usableBottom = geom.margin.bottom;
1020
+ var pages = [], current = [], y = usableTop;
1021
+
1022
+ function newPage() { pages.push(current); current = []; y = usableTop; }
1023
+
1024
+ for (var i = 0; i < blocks.length; i++) {
1025
+ var b = blocks[i];
1026
+ if (b.type === "pagebreak") { if (current.length) newPage(); continue; }
1027
+
1028
+ if (b.type === "rule") {
1029
+ if (y - baseSize < usableBottom) newPage();
1030
+ y -= baseSize * 0.6;
1031
+ current.push({ op: "rule", y: y, indent: b.indent });
1032
+ y -= baseSize * 0.6;
1033
+ continue;
1034
+ }
1035
+
1036
+ if (b.type === "table") {
1037
+ y -= b.spaceBefore;
1038
+ for (var r = 0; r < b.rows.length; r++) {
1039
+ var row = b.rows[r];
1040
+ if (y - row.height < usableBottom) {
1041
+ newPage();
1042
+ // Repeat the header row on the continuation page, or the
1043
+ // rest of the table arrives as unlabelled numbers.
1044
+ if (b.rows[0] && b.rows[0].header && r > 0) {
1045
+ current.push({ op: "row", row: b.rows[0], y: y, block: b });
1046
+ y -= b.rows[0].height;
1047
+ }
1048
+ }
1049
+ current.push({ op: "row", row: row, y: y, block: b });
1050
+ y -= row.height;
1051
+ }
1052
+ y -= b.spaceAfter;
1053
+ continue;
1054
+ }
1055
+
1056
+ y -= b.spaceBefore;
1057
+ for (var l = 0; l < b.lines.length; l++) {
1058
+ var line = b.lines[l];
1059
+ if (y - line.height < usableBottom) newPage();
1060
+ current.push({ op: "line", line: line, y: y, block: b, first: l === 0 });
1061
+ y -= line.height;
1062
+ }
1063
+ y -= b.spaceAfter;
1064
+
1065
+ // keepWithNext: if a heading ended up as the last thing on the page,
1066
+ // move it to the next one rather than orphaning it.
1067
+ if (b.keepWithNext && current.length && blocks[i + 1] && blocks[i + 1].type !== "pagebreak") {
1068
+ var nextHeight = estimateFirstLine(blocks[i + 1]);
1069
+ if (y - nextHeight < usableBottom) {
1070
+ var moved = [];
1071
+ while (current.length && current[current.length - 1].block === b) moved.unshift(current.pop());
1072
+ if (current.length) {
1073
+ newPage();
1074
+ for (var m = 0; m < moved.length; m++) {
1075
+ moved[m].y = y;
1076
+ current.push(moved[m]);
1077
+ y -= moved[m].line.height;
1078
+ }
1079
+ } else {
1080
+ // Already at the top of a page: nothing to gain by moving.
1081
+ for (var m2 = 0; m2 < moved.length; m2++) current.push(moved[m2]);
1082
+ }
1083
+ }
1084
+ }
1085
+ }
1086
+ if (current.length || !pages.length) pages.push(current);
1087
+ return pages;
1088
+ }
1089
+
1090
+ function estimateFirstLine(block) {
1091
+ if (block.type === "table") return block.rows[0] ? block.rows[0].height : 0;
1092
+ if (block.lines && block.lines[0]) return block.lines[0].height;
1093
+ return 0;
1094
+ }
1095
+
1096
+ // Type0 font over a CIDFontType2 subset: Identity-H codes are glyph IDs, /W carries the widths layout used,
1097
+ // and ToUnicode maps each glyph back to its character for copy, search and screen readers.
1098
+ function addUnicodeFont(pdf, uni) {
1099
+ var font = uni.font, scale = 1000 / font.units;
1100
+ var gids = Object.keys(uni.used).map(Number).sort(function (a, b) { return a - b; });
1101
+ var sub = subsetTrueType(font, uni.used);
1102
+ var fileId = pdf.add({ dict: "<< /Length1 " + sub.length + " /Length 0 >>", stream: sub });
1103
+ var name = "/RTEUNI+EmbeddedUnicode";
1104
+ var descId = pdf.add("<< /Type /FontDescriptor /FontName " + name + " /Flags 4 /FontBBox [" +
1105
+ font.bbox.map(function (v) { return Math.round(v * scale); }).join(" ") + "] /ItalicAngle " + font.italicAngle +
1106
+ " /Ascent " + Math.round(font.ascent * scale) + " /Descent " + Math.round(font.descent * scale) +
1107
+ " /CapHeight " + Math.round(font.capHeight * scale) + " /StemV 80 /FontFile2 " + fileId + " 0 R >>");
1108
+ var w = gids.map(function (g) { return g + " [" + Math.round(font.advance(g) * scale) + "]"; }).join(" ");
1109
+ var cidId = pdf.add("<< /Type /Font /Subtype /CIDFontType2 /BaseFont " + name +
1110
+ " /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor " + descId +
1111
+ " 0 R /DW 1000 /W [" + w + "] /CIDToGIDMap /Identity >>");
1112
+ var cmap = "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n" +
1113
+ "/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n" +
1114
+ "1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n";
1115
+ for (var i = 0; i < gids.length; i += 100) {
1116
+ var chunk = gids.slice(i, i + 100);
1117
+ cmap += chunk.length + " beginbfchar\n";
1118
+ for (var j = 0; j < chunk.length; j++) {
1119
+ var cp = uni.used[chunk[j]], u = cp > 0xFFFF ? hex4(0xD800 + ((cp - 0x10000) >> 10)) + hex4(0xDC00 + ((cp - 0x10000) & 0x3FF)) : hex4(cp);
1120
+ cmap += "<" + hex4(chunk[j]) + "> <" + u + ">\n";
1121
+ }
1122
+ cmap += "endbfchar\n";
1123
+ }
1124
+ cmap += "endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend";
1125
+ var tuId = pdf.add({ dict: "<< /Length 0 >>", stream: cmap });
1126
+ return pdf.add("<< /Type /Font /Subtype /Type0 /BaseFont " + name + " /Encoding /Identity-H /DescendantFonts [" +
1127
+ cidId + " 0 R] /ToUnicode " + tuId + " 0 R >>");
1128
+ }
1129
+
1130
+ function emit(pages, geom, familyKey, baseSize, options) {
1131
+ var pdf = new PdfWriter();
1132
+ var fam = FONT_FAMILIES[familyKey];
1133
+ var mono = FONT_FAMILIES.courier;
1134
+
1135
+ // Font objects: four faces of the body family plus four of Courier, each
1136
+ // carrying the widths this layout was computed with.
1137
+ var fontIds = {}, fontRes = [];
1138
+ function addFont(name, key, styleIndex, alias) {
1139
+ var widths = widthsFor(key, styleIndex);
1140
+ var id = pdf.add("<< /Type /Font /Subtype /Type1 /BaseFont /" + name +
1141
+ " /Encoding /WinAnsiEncoding /FirstChar " + FIRST_CHAR + " /LastChar " + LAST_CHAR +
1142
+ " /Widths [" + widths.join(" ") + "] >>");
1143
+ fontIds[alias] = id;
1144
+ fontRes.push("/" + alias + " " + id + " 0 R");
1145
+ }
1146
+ for (var s = 0; s < 4; s++) {
1147
+ addFont(fam.faces[s], familyKey, s, "F" + s);
1148
+ addFont(mono.faces[s], "courier", s, "M" + s);
1149
+ }
1150
+
1151
+ var images = {}, imageRes = [];
1152
+ function imageRef(img) {
1153
+ var src = img.getAttribute("src") || "";
1154
+ if (images[src]) return images[src];
1155
+ var data = encodeImage(img);
1156
+ if (!data) return null;
1157
+ var id = pdf.add({
1158
+ dict: "<< /Type /XObject /Subtype /Image /Width " + data.width + " /Height " + data.height +
1159
+ " /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length 0 >>",
1160
+ stream: data.bytes
1161
+ });
1162
+ var alias = "Im" + (imageRes.length + 1);
1163
+ images[src] = { id: id, alias: alias };
1164
+ imageRes.push("/" + alias + " " + id + " 0 R");
1165
+ return images[src];
1166
+ }
1167
+
1168
+ var pageIds = [], contentIds = [], annotIds = [];
1169
+ for (var p = 0; p < pages.length; p++) {
1170
+ pageIds.push(pdf.alloc());
1171
+ contentIds.push(pdf.alloc());
1172
+ annotIds.push([]);
1173
+ }
1174
+ var pagesId = pdf.alloc();
1175
+
1176
+ var allMarks = [], heads = [], linkAnnots = [];
1177
+ for (var pi = 0; pi < pages.length; pi++) {
1178
+ var tagger = new Tagger(pi);
1179
+ var draw = renderPage(pages[pi], geom, familyKey, baseSize, imageRef, pi + 1, pages.length, options, tagger);
1180
+ for (var hd = 0; hd < (draw.headings || []).length; hd++) { draw.headings[hd].page = pi; heads.push(draw.headings[hd]); }
1181
+ pdf.set(contentIds[pi], { dict: "<< /Length 0 >>", stream: draw.content });
1182
+ allMarks.push(draw.marks);
1183
+ for (var a = 0; a < draw.links.length; a++) {
1184
+ var lk = draw.links[a];
1185
+ // 2026-10-15 each link annotation is in the tag tree: /StructParent (keys after the pages'), /Contents
1186
+ // naming it, and a Link element built in buildStructTree. They were click targets a screen reader
1187
+ // could not find (PDF/UA 7.18, Matterhorn 28-011 / 28-004). tools/pdf/link-tags.mjs
1188
+ var lsp = pages.length + linkAnnots.length;
1189
+ var lid = pdf.add("<< /Type /Annot /Subtype /Link /Rect [" +
1190
+ fixed(lk.x0) + " " + fixed(lk.y0) + " " + fixed(lk.x1) + " " + fixed(lk.y1) +
1191
+ "] /Border [0 0 0] /StructParent " + lsp + " /Contents " + pdfText(String(lk.text || lk.href).replace(/\s+/g, " ").trim() || lk.href) +
1192
+ " /A << /S /URI /URI (" + escapeString(pdfUri(lk.href)) + ") >> >>");
1193
+ annotIds[pi].push(lid);
1194
+ linkAnnots.push({ id: lid, page: pi, key: lsp, owner: lk.owner });
1195
+ }
1196
+ for (var cm = 0; cm < (draw.comments || []).length; cm++) {
1197
+ var cmt = draw.comments[cm];
1198
+ // 2026-10-21 a note is tagged like a link (PDF/UA 7.18, the rule the link tags already follow): it has a
1199
+ // /StructParent key, a Note element names it, and the parent tree maps the key to that element. tools/pdf/comments.mjs
1200
+ var noteKey = pages.length + linkAnnots.length;
1201
+ var noteId = pdf.add("<< /Type /Annot /Subtype /Text /Rect [" + fixed(cmt.x0) + " " + fixed(cmt.y0) + " " + fixed(cmt.x1) + " " + fixed(cmt.y1) + "]" +
1202
+ " /Contents " + pdfText(cmt.text) + " /T " + pdfText(cmt.author) + " /Name /Note /Open false /C [1 1 0] /StructParent " + noteKey + " >>");
1203
+ annotIds[pi].push(noteId);
1204
+ linkAnnots.push({ id: noteId, page: pi, key: noteKey, owner: null, note: true });
1205
+ }
1206
+ }
1207
+
1208
+ if (activeUni && Object.keys(activeUni.used).length) fontRes.push("/U0 " + addUnicodeFont(pdf, activeUni) + " 0 R");
1209
+
1210
+ var struct = buildStructTree(pdf, allMarks, pageIds, linkAnnots);
1211
+
1212
+ for (var pj = 0; pj < pages.length; pj++) {
1213
+ pdf.set(pageIds[pj], "<< /Type /Page /Parent " + pagesId + " 0 R /MediaBox [0 0 " +
1214
+ fixed(geom.width) + " " + fixed(geom.height) + "] /Resources << /Font << " + fontRes.join(" ") + " >>" +
1215
+ (imageRes.length ? " /XObject << " + imageRes.join(" ") + " >>" : "") +
1216
+ " >> /Contents " + contentIds[pj] + " 0 R /StructParents " + pj +
1217
+ (annotIds[pj].length ? " /Annots [" + annotIds[pj].map(function (id) { return id + " 0 R"; }).join(" ") + "] /Tabs /S" : "") +
1218
+ " >>");
1219
+ }
1220
+ pdf.set(pagesId, "<< /Type /Pages /Count " + pages.length + " /Kids [" +
1221
+ pageIds.map(function (id) { return id + " 0 R"; }).join(" ") + "] >>");
1222
+
1223
+ // 2026-10-15 bookmarks from the headings, nested by level, each opening its heading's page at the heading
1224
+ // (Word and Chrome export them; a long report had nothing but scrolling - WCAG 2.4.5, PDF2).
1225
+ // tools/pdf/outline-bookmarks.mjs
1226
+ var outlineId = 0;
1227
+ if (heads.length) {
1228
+ outlineId = pdf.alloc();
1229
+ var oroot = { id: outlineId, kids: [] }, ostack = [oroot], onodes = [];
1230
+ for (var oh = 0; oh < heads.length; oh++) {
1231
+ var on = { h: heads[oh], id: pdf.alloc(), kids: [], parent: null };
1232
+ while (ostack.length > 1 && ostack[ostack.length - 1].h.level >= on.h.level) ostack.pop();
1233
+ on.parent = ostack[ostack.length - 1];
1234
+ on.parent.kids.push(on);
1235
+ ostack.push(on);
1236
+ onodes.push(on);
1237
+ }
1238
+ var ocount = function (n) { var c = n.kids.length; for (var k = 0; k < n.kids.length; k++) c += ocount(n.kids[k]); return c; };
1239
+ for (var oi = 0; oi < onodes.length; oi++) {
1240
+ var n = onodes[oi], sib = n.parent.kids, at = sib.indexOf(n);
1241
+ pdf.set(n.id, "<< /Title " + pdfText(n.h.title) + " /Parent " + n.parent.id + " 0 R" +
1242
+ (at > 0 ? " /Prev " + sib[at - 1].id + " 0 R" : "") + (at < sib.length - 1 ? " /Next " + sib[at + 1].id + " 0 R" : "") +
1243
+ (n.kids.length ? " /First " + n.kids[0].id + " 0 R /Last " + n.kids[n.kids.length - 1].id + " 0 R /Count " + ocount(n) : "") +
1244
+ " /Dest [" + pageIds[n.h.page] + " 0 R /XYZ null " + fixed(n.h.y) + " null] >>");
1245
+ }
1246
+ pdf.set(outlineId, "<< /Type /Outlines /First " + oroot.kids[0].id + " 0 R /Last " + oroot.kids[oroot.kids.length - 1].id + " 0 R /Count " + ocount(oroot) + " >>");
1247
+ }
1248
+
1249
+ var title = options.title || config.pdfExportTitle || document.title || "Document";
1250
+ var author = options.author || config.pdfExportAuthor || "";
1251
+ // /MarkInfo and /StructTreeRoot declare the file tagged. DisplayDocTitle
1252
+ // makes the viewer show the document's title rather than its filename,
1253
+ // which PDF/UA requires and which is the difference between a screen
1254
+ // reader announcing "Quarterly Report" and "Export-260802-1431.pdf".
1255
+ var catalogId = pdf.add("<< /Type /Catalog /Pages " + pagesId + " 0 R" +
1256
+ " /Lang (" + escapeString(String(options.lang || document.documentElement.lang || "en")) + ")" +
1257
+ " /MarkInfo << /Marked true >>" +
1258
+ (outlineId ? " /Outlines " + outlineId + " 0 R" : "") +
1259
+ " /StructTreeRoot " + struct.rootId + " 0 R" +
1260
+ " /ViewerPreferences << /DisplayDocTitle true >> >>");
1261
+ var infoId = pdf.add("<< /Title " + pdfText(String(title)) +
1262
+ (author ? " /Author " + pdfText(String(author)) : "") +
1263
+ " /Producer (RichTextEditor) /Creator (RichTextEditor) /CreationDate (" + pdfDate(new Date()) + ") >>");
1264
+
1265
+ return pdf.build(catalogId, infoId);
1266
+ }
1267
+
1268
+ // Turn the per-page marked-content records into a structure tree.
1269
+ //
1270
+ // The tree is built from the marks rather than from the block list because a
1271
+ // single block can be split across pages: its structure element then holds
1272
+ // several marked-content references, each naming the page it lives on.
1273
+ function buildStructTree(pdf, allMarks, pageIds, linkAnnots) {
1274
+ var rootId = pdf.alloc();
1275
+ var nodes = []; // { id, role, kids, extra, children, parent }
1276
+ var parentTree = []; // per page: array of struct ids by MCID
1277
+
1278
+ function node(role, extra) {
1279
+ var n = { id: pdf.alloc(), role: role, kids: [], extra: extra || "", children: [], parent: null, seq: [] };
1280
+ nodes.push(n);
1281
+ return n;
1282
+ }
1283
+
1284
+ var docNode = node("Document");
1285
+ var listNodes = new Map(); // listId -> L node
1286
+ var itemNodes = new Map(); // block -> LI node
1287
+ var labelNodes = new Map(); // block -> Lbl node
1288
+ var bodyNodes = new Map(); // block -> P / LBody / Figure node
1289
+ var tableNodes = new Map(); // block -> Table node
1290
+ var rowNodes = new Map(); // row -> TR node
1291
+ var cellNodes = new Map(); // row -> { col -> TD/TH node }
1292
+ var linkNodes = new Map(); // link owner -> Link node (2026-10-17, tools/pdf/link-order.mjs)
1293
+
1294
+ for (var p = 0; p < allMarks.length; p++) {
1295
+ var marks = allMarks[p];
1296
+ parentTree[p] = [];
1297
+ for (var m = 0; m < marks.length; m++) {
1298
+ var owner = marks[m].owner, mcid = marks[m].mcid;
1299
+ var target = resolve(owner);
937
1300
  target.kids.push({ page: p, mcid: mcid });
938
- target.seq.push({ mcr: { page: p, mcid: mcid } });
939
- parentTree[p][mcid] = target.id;
940
- }
941
- }
942
-
943
- // The parent is recorded when the child is attached. Searching for it
944
- // afterwards would be a scan of every node for every node.
945
- function attach(parent, child) {
946
- if (child.parent) return;
947
- child.parent = parent;
948
- parent.children.push(child);
949
- parent.seq.push({ elem: child });
950
- }
951
- // a Link element per link annotation, under the paragraph / cell holding it
952
- var linkKeys = [];
953
- for (var la = 0; la < (linkAnnots || []).length; la++) {
954
- var lnk;
955
- if (linkAnnots[la].owner && linkAnnots[la].owner.kind === "link") lnk = resolve(linkAnnots[la].owner);
956
- else { lnk = node("Link"); attach(resolve(linkAnnots[la].owner), lnk); }
957
- lnk.objr = linkAnnots[la];
958
- linkKeys.push(linkAnnots[la].key + " " + lnk.id + " 0 R");
959
- }
960
-
961
- function resolve(owner) {
962
- // 2026-10-17 a link's words: their own Link element under the paragraph or cell, created where they occur
963
- if (owner.kind === "link") {
964
- var ln = linkNodes.get(owner);
965
- if (!ln) { ln = node("Link"); linkNodes.set(owner, ln); attach(resolve(owner.parent), ln); }
966
- return ln;
967
- }
968
- if (owner.kind === "cell") {
969
- var table = tableNodes.get(owner.block);
970
- if (!table) { table = node("Table"); tableNodes.set(owner.block, table); attach(docNode, table); }
971
- var row = rowNodes.get(owner.row);
972
- if (!row) { row = node("TR"); rowNodes.set(owner.row, row); attach(table, row); }
973
- var perRow = cellNodes.get(owner.row);
974
- if (!perRow) { perRow = {}; cellNodes.set(owner.row, perRow); }
975
- if (!perRow[owner.col]) {
976
- // A header cell must be TH, not TD: it is what lets a screen
977
- // reader announce "Revenue, 1,240" instead of just "1,240".
978
- // 2026-10-15 table attributes belong in /A << /O /Table >> (where PDF/UA checkers read them; /Scope
979
- // sat on the element), with /ColSpan for a merged cell. tools/pdf/table-colspan.mjs
980
- var tattr = (owner.header ? " /Scope /Column" : "") + (owner.span > 1 ? " /ColSpan " + owner.span : "") + (owner.rowspan > 1 ? " /RowSpan " + owner.rowspan : "");
981
- perRow[owner.col] = node(owner.header ? "TH" : "TD", tattr ? " /A << /O /Table" + tattr + " >>" : "");
982
- attach(row, perRow[owner.col]);
983
- }
984
- return perRow[owner.col];
985
- }
986
-
987
- var block = owner.block;
988
- var container = docNode;
989
- if (block.listId) {
990
- var listNode = listNodes.get(block.listId);
991
- if (!listNode) { listNode = node("L"); listNodes.set(block.listId, listNode); attach(docNode, listNode); }
992
- var itemNode = itemNodes.get(block);
993
- if (!itemNode) { itemNode = node("LI"); itemNodes.set(block, itemNode); attach(listNode, itemNode); }
994
- container = itemNode;
995
- }
996
-
997
- if (owner.kind === "lbl") {
998
- var lbl = labelNodes.get(block);
999
- if (!lbl) { lbl = node("Lbl"); labelNodes.set(block, lbl); attach(container, lbl); }
1000
- return lbl;
1001
- }
1002
-
1003
- var body = bodyNodes.get(block);
1004
- if (!body) {
1005
- if (block.listId) {
1006
- body = node("LBody");
1007
- } else {
1008
- // A figure with no alt text is an accessibility failure, but
1009
- // an EMPTY /Alt is a worse one: it tells assistive tech the
1010
- // image is decorative when nobody decided that.
1011
- var extra = block.role === "Figure"
1012
- ? " /Alt (" + escapeString(toWinAnsi(block.alt || "Image")) + ")"
1013
- : "";
1014
- // 2026-10-15 a block in another language says so, or it is read with the document's
1015
- // pronunciation (WCAG 3.1.2, PDF/UA 7.2). tools/pdf/paragraph-lang.mjs
1016
- var docLang = pdfDocLang;
1017
- var bl = String(block.lang || "").replace(/[^A-Za-z0-9-]/g, "");
1018
- if (bl && bl.toLowerCase() !== docLang) extra += " /Lang (" + bl + ")";
1019
- body = node(block.role || "P", extra);
1020
- }
1021
- bodyNodes.set(block, body);
1022
- attach(container, body);
1023
- }
1024
- return body;
1025
- }
1026
-
1027
- // Emit. /K lists child elements and marked-content references in reading order (2026-10-17: child elements
1028
- // used to come first, so a Link sat at the start of its paragraph). tools/pdf/link-order.mjs
1029
- for (var n = 0; n < nodes.length; n++) {
1030
- var nd = nodes[n];
1031
- var kids = [];
1032
- for (var k = 0; k < nd.seq.length; k++) {
1033
- var sq = nd.seq[k];
1034
- if (sq.elem) kids.push(sq.elem.id + " 0 R");
1035
- else kids.push("<< /Type /MCR /Pg " + pageIds[sq.mcr.page] + " 0 R /MCID " + sq.mcr.mcid + " >>");
1036
- }
1037
- if (nd.objr) kids.push("<< /Type /OBJR /Obj " + nd.objr.id + " 0 R /Pg " + pageIds[nd.objr.page] + " 0 R >>");
1038
- var parentRef = nd.parent ? nd.parent.id : rootId;
1039
- pdf.set(nd.id, "<< /Type /StructElem /S /" + nd.role + " /P " + parentRef + " 0 R" + nd.extra +
1040
- (kids.length ? " /K [" + kids.join(" ") + "]" : "") + " >>");
1041
- }
1042
-
1043
- // ParentTree: a number tree keyed by each page's /StructParents value,
1044
- // whose value is the list of owning structure elements indexed by MCID.
1045
- var numsParts = [];
1046
- for (var pt = 0; pt < parentTree.length; pt++) {
1047
- var arr = parentTree[pt] || [];
1048
- var refs = [];
1049
- for (var q = 0; q < arr.length; q++) refs.push((arr[q] || docNode.id) + " 0 R");
1050
- numsParts.push(pt + " [" + refs.join(" ") + "]");
1051
- }
1052
- var parentTreeId = pdf.add("<< /Nums [" + numsParts.concat(linkKeys).join(" ") + "] >>");
1053
-
1054
- pdf.set(rootId, "<< /Type /StructTreeRoot /K [" + docNode.id + " 0 R]" +
1055
- " /ParentTree " + parentTreeId + " 0 R /ParentTreeNextKey " + (parentTree.length + linkKeys.length) + " >>");
1056
-
1057
- return { rootId: rootId };
1058
- }
1059
-
1060
- function pdfDate(d) {
1061
- function two(n) { return (n < 10 ? "0" : "") + n; }
1062
- return "D:" + d.getFullYear() + two(d.getMonth() + 1) + two(d.getDate()) +
1063
- two(d.getHours()) + two(d.getMinutes()) + two(d.getSeconds()) + "Z";
1064
- }
1065
- function fixed(n) { return (Math.round(n * 100) / 100).toString(); }
1066
-
1067
- // Marked content for the structure tree.
1068
- //
1069
- // Tagging is what separates "a screen reader can read the words" from "a
1070
- // screen reader knows this is a level-2 heading, followed by a 4-item list,
1071
- // followed by a table with header cells". Section 508 and EN 301 549 both
1072
- // require the second. Purely decorative output (rules, cell borders, the
1073
- // running header) is marked as an Artifact instead, which removes it from
1074
- // the reading order rather than reading "line, line, line" to the user.
1075
- function Tagger(pageIndex) {
1076
- this.pageIndex = pageIndex;
1077
- this.next = 0;
1078
- this.marks = []; // { owner, mcid } in reading order
1079
- }
1080
- Tagger.prototype.open = function (out, role, owner) {
1081
- var mcid = this.next++;
1082
- this.marks.push({ owner: owner, mcid: mcid, role: role });
1083
- out.push("/" + role + " << /MCID " + mcid + " >> BDC");
1084
- return mcid;
1085
- };
1086
- Tagger.prototype.close = function (out) { out.push("EMC"); };
1087
- Tagger.prototype.artifact = function (out, fn) {
1088
- out.push("/Artifact BMC");
1089
- fn();
1090
- out.push("EMC");
1091
- };
1092
-
1093
- function renderPage(ops, geom, familyKey, baseSize, imageRef, pageNumber, pageCount, options, tagger) {
1094
- var out = [], links = [], headings = [];
1095
- var left = geom.margin.left;
1096
-
1097
- for (var i = 0; i < ops.length; i++) {
1098
- var op = ops[i];
1099
- // 2026-10-15 where each heading starts, for the bookmarks. tools/pdf/outline-bookmarks.mjs
1100
- if (op.op === "line" && op.first && op.block && /^H[1-6]$/.test(op.block.role || "")) {
1101
- var htext = (op.block.lines || []).map(function (ln) { return (ln.parts || []).map(function (pt) { return pt.text || ""; }).join(""); }).join(" ").replace(/\s+/g, " ").trim();
1102
- if (htext) headings.push({ level: +op.block.role.charAt(1), title: htext, y: op.y });
1103
- }
1104
- if (op.op === "rule") {
1105
- // A horizontal rule is decoration, not content.
1106
- tagger.artifact(out, function () {
1107
- out.push("0.8 0.8 0.8 RG 0.5 w " + fixed(left + op.indent) + " " + fixed(op.y) + " m " +
1108
- fixed(geom.width - geom.margin.right) + " " + fixed(op.y) + " l S");
1109
- });
1110
- continue;
1111
- }
1112
- if (op.op === "row") { renderRow(op, geom, out, left, imageRef, links, tagger); continue; }
1113
- renderLine(op, geom, out, left, familyKey, imageRef, links, tagger);
1114
- }
1115
-
1116
- // Running header/footer, drawn after the body so they always sit on top.
1117
- // Both are artifacts: repeating them into the reading order on every
1118
- // page is one of the most common findings in a PDF accessibility audit.
1119
- var hf = headerFooter(options);
1120
- if (hf.header || hf.footer) {
1121
- tagger.artifact(out, function () {
1122
- if (hf.header) drawRunning(out, hf.header, geom, geom.height - geom.margin.top * 0.55, familyKey, baseSize * 0.85, pageNumber, pageCount);
1123
- if (hf.footer) drawRunning(out, hf.footer, geom, geom.margin.bottom * 0.55, familyKey, baseSize * 0.85, pageNumber, pageCount);
1124
- });
1125
- }
1126
-
1127
- return { content: out.join("\n"), links: links, marks: tagger.marks, headings: headings };
1128
- }
1129
-
1130
- function headerFooter(options) {
1131
- var setup = null;
1132
- try { if (typeof editor.getDocumentPageSetup === "function") setup = editor.getDocumentPageSetup(); } catch (e) {}
1133
- setup = setup || {};
1134
- return {
1135
- header: options.headerText || config.pdfExportHeader || plainText(setup.headerHtml) || "",
1136
- footer: options.footerText || config.pdfExportFooter || plainText(setup.footerHtml) || ""
1137
- };
1138
- }
1139
- function plainText(html) {
1140
- if (!html || typeof html !== "string") return "";
1141
- var d = document.createElement("div");
1142
- d.innerHTML = html;
1143
- return (d.textContent || "").replace(/\s+/g, " ").trim();
1144
- }
1145
-
1146
- function drawRunning(out, template, geom, y, familyKey, size, pageNumber, pageCount) {
1147
- var text = String(template).replace(/\{page\}/g, pageNumber).replace(/\{total\}/g, pageCount);
1148
- var win = toWinAnsi(text);
1149
- var w = textWidth(win, familyKey, 0, size);
1150
- var x = (geom.width - w) / 2;
1151
- out.push("BT /F0 " + fixed(size) + " Tf 0.4 0.4 0.4 rg " + fixed(x) + " " + fixed(y) + " Td (" + escapeString(win) + ") Tj ET");
1152
- }
1153
-
1154
- function renderLine(op, geom, out, left, familyKey, imageRef, links, tagger) {
1155
- var line = op.line, block = op.block;
1156
- var x = left + (block.indent || 0);
1157
- var avail = geom.width - geom.margin.right - x;
1158
- if (block.align === "center") x += (avail - line.width) / 2;
1159
- else if (block.align === "right") x += avail - line.width;
1160
-
1161
- if (block.rule === "quote" && !line.image) {
1162
- // The quote bar is decoration.
1163
- var barX = fixed(left + block.indent - 8);
1164
- var run = function () {
1165
- out.push("0.8 0.8 0.85 RG 2 w " + barX + " " + fixed(op.y - line.height + 4) +
1166
- " m " + barX + " " + fixed(op.y + line.size * 0.8) + " l S");
1167
- };
1168
- if (tagger) tagger.artifact(out, run); else run();
1169
- }
1170
-
1171
- // A list marker belongs to the FIRST line only; repeating it on a wrapped
1172
- // continuation line turns one bullet into several.
1173
- if (op.first && block.marker) {
1174
- var ms = block.markerStyle || { size: line.size, color: [0, 0, 0] };
1175
- var mw = textWidth(block.marker, familyKey, 0, ms.size);
1176
- if (tagger) tagger.open(out, "Lbl", { kind: "lbl", block: block });
1177
- out.push("BT /F0 " + fixed(ms.size) + " Tf " + colorOp(ms.color) + " " +
1178
- fixed(x - mw - 6) + " " + fixed(op.y - line.size * 0.85) + " Td (" + escapeString(toWinAnsi(block.marker)) + ") Tj ET");
1179
- if (tagger) tagger.close(out);
1180
- }
1181
-
1182
- // Text of the block itself. Figures carry /Alt on the structure element
1183
- // rather than in the content stream.
1184
- //
1185
- // A table cell renders through this function with a SYNTHETIC block
1186
- // (just indent/align), so the owner has to be the cell descriptor the
1187
- // caller supplied. Passing the synthetic block instead makes every cell
1188
- // a top-level paragraph and the table loses its structure entirely.
1189
- var lineOwner = op.cell || { kind: "body", block: block };
1190
- var lineOpen = false;
1191
- if (tagger) { tagger.open(out, line.image ? "Figure" : "Span", lineOwner); lineOpen = true; }
1192
-
1193
- // Wrapping split the text into words, but emitting one BT/Tj block per
1194
- // WORD is wrong twice over: the content stream balloons, and text
1195
- // extractors that infer spacing from separate show-operations can lose
1196
- // the spaces between them, so copying a paragraph out of the PDF yields
1197
- // "onelongrunofwords". Adjacent parts that share a font, size and colour
1198
- // are merged back into a single run, spaces included.
1199
- var segments = [];
1200
- var cursor = x;
1201
- for (var i = 0; i < line.parts.length; i++) {
1202
- var part = line.parts[i];
1203
- if (part.img) { segments.push({ img: part, x: cursor, w: part.w }); cursor += part.w; continue; }
1204
- var st = part.style;
1205
- var alias = (st.mono ? "M" : "F") + part.styleIndex;
1206
- var last = segments.length ? segments[segments.length - 1] : null;
1207
- var mergeable = last && !last.img && last.alias === alias && last.style.size === st.size &&
1208
- sameColor(last.style.color, st.color) && last.style.underline === st.underline &&
1209
- last.style.strike === st.strike && last.style.link === st.link && (last.style.lang || "") === (st.lang || "");
1210
- if (mergeable) { last.text += part.text; last.w += part.w; }
1211
- else { segments.push({ text: part.text, style: st, alias: alias, x: cursor, w: part.w }); }
1212
- cursor += part.w;
1213
- }
1214
-
1215
- var baseline = op.y - line.size * 0.85;
1216
- for (var s = 0; s < segments.length; s++) {
1217
- var seg = segments[s];
1218
- if (seg.img) {
1219
- var ref = imageRef(seg.img.img);
1220
- if (ref) {
1221
- out.push("q " + fixed(seg.w) + " 0 0 " + fixed(seg.img.h) + " " + fixed(seg.x) + " " +
1222
- fixed(op.y - seg.img.h) + " cm /" + ref.alias + " Do Q");
1223
- }
1224
- continue;
1225
- }
1226
- // A run of pure whitespace still advanced the cursor; drawing it
1227
- // would only add an empty operation.
1228
- if (!seg.text || /^\s+$/.test(seg.text)) continue;
1229
- var sst = seg.style;
1230
- // 2026-10-17 a link's words are marked content of their own, owned by the Link element, so the Link holds
1231
- // its text at its place in the paragraph (PDF/UA 7.18.5). tools/pdf/link-order.mjs
1232
- var linkOwner = null;
1233
- if (tagger && !line.image && sst.link) {
1234
- if (lineOpen) { tagger.close(out); lineOpen = false; }
1235
- linkOwner = { kind: "link", parent: lineOwner };
1236
- tagger.open(out, "Span", linkOwner);
1237
- } else if (tagger && !lineOpen) {
1238
- tagger.open(out, "Span", lineOwner); lineOpen = true;
1239
- }
1240
- // 2026-10-17 a run in another language than its block (a French phrase in an English sentence) is drawn
1241
- // inside marked content with a /Lang property, so it is read with the right pronunciation (WCAG 3.1.2,
1242
- // PDF/UA 7.2). The paragraph's own /Lang is on its structure element. tools/pdf/inline-lang.mjs
1243
- var runLang = String(sst.lang || "").replace(/[^A-Za-z0-9-]/g, "");
1244
- var langMark = runLang && runLang.toLowerCase() !== String(block.lang || pdfDocLang || "").toLowerCase();
1245
- if (langMark) out.push("/Span << /Lang (" + runLang + ") >> BDC");
1246
- out.push("BT /" + seg.alias + " " + fixed(sst.size) + " Tf " + colorOp(sst.color) + " " +
1247
- fixed(seg.x) + " " + fixed(baseline) + " Td (" + escapeString(toWinAnsi(seg.text)) + ") Tj ET");
1248
- if (langMark) out.push("EMC");
1249
- if (sst.underline) {
1250
- out.push(colorStroke(sst.color) + " 0.5 w " + fixed(seg.x) + " " + fixed(baseline - sst.size * 0.12) +
1251
- " m " + fixed(seg.x + seg.w) + " " + fixed(baseline - sst.size * 0.12) + " l S");
1252
- }
1253
- if (sst.strike) {
1254
- out.push(colorStroke(sst.color) + " 0.5 w " + fixed(seg.x) + " " + fixed(baseline + sst.size * 0.28) +
1255
- " m " + fixed(seg.x + seg.w) + " " + fixed(baseline + sst.size * 0.28) + " l S");
1256
- }
1257
- if (sst.link) {
1258
- links.push({ href: sst.link, x0: seg.x, y0: baseline - 2, x1: seg.x + seg.w, y1: baseline + sst.size, text: seg.text || "", owner: linkOwner || lineOwner });
1259
- }
1260
- if (linkOwner) tagger.close(out);
1261
- }
1262
- if (tagger && lineOpen) tagger.close(out);
1263
- }
1264
-
1265
- function sameColor(a, b) {
1266
- a = a || [0, 0, 0]; b = b || [0, 0, 0];
1267
- return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1268
- }
1269
-
1270
- function renderRow(op, geom, out, left, imageRef, links, tagger) {
1271
- var block = op.block, row = op.row;
1272
- var x = left + block.indent;
1273
- var top = op.y, bottom = op.y - row.height;
1274
-
1275
- for (var c = 0; c < row.cells.length; c++) {
1276
- var cspan = row.cells[c].span || 1, cstart = row.cells[c].start != null ? row.cells[c].start : c;
1277
- var cellX = x, cellW = 0;
1278
- for (var wi = 0; wi < cstart + cspan; wi++) {
1279
- var colw = block.colWidths && block.colWidths[wi] != null ? block.colWidths[wi] : block.colWidth;
1280
- if (wi < cstart) cellX += colw; else cellW += colw;
1281
- }
1282
- // Shading and borders are decoration; only the cell TEXT belongs to
1283
- // the reading order.
1284
- var paint = function () {
1285
- if (row.header) {
1286
- out.push("0.94 0.95 0.97 rg " + fixed(cellX) + " " + fixed(bottom) + " " +
1287
- fixed(cellW) + " " + fixed(row.height) + " re f");
1288
- }
1289
- out.push("0.75 0.78 0.82 RG 0.5 w " + fixed(cellX) + " " + fixed(bottom) + " " +
1290
- fixed(cellW) + " " + fixed(row.height) + " re S");
1291
- };
1292
- if (tagger) tagger.artifact(out, paint); else paint();
1293
-
1294
- // One structure element per CELL, identified by the row object and
1295
- // column index so a table split across pages still resolves to the
1296
- // right TD (and a repeated header row to the right TH).
1297
- var cellOwner = { kind: "cell", block: block, row: row, col: cstart, span: cspan, rowspan: row.cells[c].rowspan || 1, header: !!row.cells[c].header };
1298
- var cy = top - block.pad;
1299
- var lines = row.cells[c].lines;
1300
- for (var l = 0; l < lines.length; l++) {
1301
- renderLine({ line: lines[l], y: cy, block: { indent: 0, align: "left" }, first: l === 0, cell: cellOwner },
1302
- { width: cellX + cellW + geom.margin.right, margin: { right: geom.margin.right } },
1303
- out, cellX + block.pad, "helvetica", imageRef, links, tagger);
1304
- cy -= lines[l].height;
1305
- }
1306
- }
1307
- }
1308
-
1309
- function colorOp(c) { c = c || [0, 0, 0]; return fixed(c[0]) + " " + fixed(c[1]) + " " + fixed(c[2]) + " rg"; }
1310
- function colorStroke(c) { c = c || [0, 0, 0]; return fixed(c[0]) + " " + fixed(c[1]) + " " + fixed(c[2]) + " RG"; }
1311
-
1312
- // Every image is re-encoded to JPEG through a canvas. PNG could be embedded
1313
- // as FlateDecode without re-encoding, but only for a narrow set of colour
1314
- // types and never with an alpha channel; routing everything through one path
1315
- // means GIF, WEBP, BMP and SVG all work rather than only some of them.
1316
- // Transparent pixels are composited onto white, which is what the page is.
1317
- function encodeImage(img) {
1318
- try {
1319
- var w = img.naturalWidth || img.width, h = img.naturalHeight || img.height;
1320
- if (!w || !h) return null;
1321
- var max = 2000;
1322
- var scale = Math.min(1, max / Math.max(w, h));
1323
- var cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(h * scale));
1324
- var canvas = document.createElement("canvas");
1325
- canvas.width = cw; canvas.height = ch;
1326
- var g = canvas.getContext("2d");
1327
- g.fillStyle = "#ffffff";
1328
- g.fillRect(0, 0, cw, ch);
1329
- g.drawImage(img, 0, 0, cw, ch);
1330
- var uri = canvas.toDataURL("image/jpeg", 0.92);
1331
- var comma = uri.indexOf(",");
1332
- if (comma < 0) return null;
1333
- var binary = atob(uri.slice(comma + 1));
1334
- var bytes = new Uint8Array(binary.length);
1335
- for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
1336
- return { bytes: bytes, width: cw, height: ch };
1337
- } catch (e) {
1338
- // A cross-origin image taints the canvas. Skipping it is correct:
1339
- // there is no way to read its pixels, and failing the whole export
1340
- // over one decorative image would be worse.
1341
- return null;
1342
- }
1343
- }
1344
-
1345
- // ------------------------------------------------------------- downloads
1346
- function sanitizeName(name) {
1347
- if (!name || typeof name !== "string") return "";
1348
- return name.replace(/\.pdf$/i, "").replace(/[\\/:*?"<>|]+/g, "").trim().slice(0, 120);
1349
- }
1350
- function defaultBase() {
1351
- var d = new Date();
1352
- function two(n) { return (n < 10 ? "0" : "") + n; }
1353
- return "Export-" + String(d.getFullYear()).slice(2) + two(d.getMonth() + 1) + two(d.getDate()) +
1354
- "-" + two(d.getHours()) + two(d.getMinutes()) + two(d.getSeconds());
1355
- }
1356
- function download(bytes, filename) {
1357
- var blob = new Blob([bytes], { type: "application/pdf" });
1358
- var url = URL.createObjectURL(blob);
1359
- var a = document.createElement("a");
1360
- a.href = url; a.download = filename;
1361
- document.body.appendChild(a);
1362
- a.click();
1363
- document.body.removeChild(a);
1364
- setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
1365
- return filename;
1366
- }
1367
- }
1301
+ target.seq.push({ mcr: { page: p, mcid: mcid } });
1302
+ parentTree[p][mcid] = target.id;
1303
+ }
1304
+ }
1305
+
1306
+ // The parent is recorded when the child is attached. Searching for it
1307
+ // afterwards would be a scan of every node for every node.
1308
+ function attach(parent, child) {
1309
+ if (child.parent) return;
1310
+ child.parent = parent;
1311
+ parent.children.push(child);
1312
+ parent.seq.push({ elem: child });
1313
+ }
1314
+ // a Link element per link annotation, under the paragraph / cell holding it
1315
+ var linkKeys = [];
1316
+ for (var la = 0; la < (linkAnnots || []).length; la++) {
1317
+ if (linkAnnots[la].note) {
1318
+ // a comment note: a Note element under the document, its OBJR and its parent-tree key
1319
+ var nte = node("Note");
1320
+ attach(docNode, nte);
1321
+ nte.objr = linkAnnots[la];
1322
+ linkKeys.push(linkAnnots[la].key + " " + nte.id + " 0 R");
1323
+ continue;
1324
+ }
1325
+ var lnk;
1326
+ if (linkAnnots[la].owner && linkAnnots[la].owner.kind === "link") lnk = resolve(linkAnnots[la].owner);
1327
+ else { lnk = node("Link"); attach(resolve(linkAnnots[la].owner), lnk); }
1328
+ lnk.objr = linkAnnots[la];
1329
+ linkKeys.push(linkAnnots[la].key + " " + lnk.id + " 0 R");
1330
+ }
1331
+
1332
+ function resolve(owner) {
1333
+ // 2026-10-17 a link's words: their own Link element under the paragraph or cell, created where they occur
1334
+ if (owner.kind === "link") {
1335
+ var ln = linkNodes.get(owner);
1336
+ if (!ln) { ln = node("Link"); linkNodes.set(owner, ln); attach(resolve(owner.parent), ln); }
1337
+ return ln;
1338
+ }
1339
+ if (owner.kind === "cell") {
1340
+ var table = tableNodes.get(owner.block);
1341
+ if (!table) { table = node("Table"); tableNodes.set(owner.block, table); attach(docNode, table); }
1342
+ var row = rowNodes.get(owner.row);
1343
+ if (!row) { row = node("TR"); rowNodes.set(owner.row, row); attach(table, row); }
1344
+ var perRow = cellNodes.get(owner.row);
1345
+ if (!perRow) { perRow = {}; cellNodes.set(owner.row, perRow); }
1346
+ if (!perRow[owner.col]) {
1347
+ // A header cell must be TH, not TD: it is what lets a screen
1348
+ // reader announce "Revenue, 1,240" instead of just "1,240".
1349
+ // 2026-10-15 table attributes belong in /A << /O /Table >> (where PDF/UA checkers read them; /Scope
1350
+ // sat on the element), with /ColSpan for a merged cell. tools/pdf/table-colspan.mjs
1351
+ var tattr = (owner.header ? " /Scope /Column" : "") + (owner.span > 1 ? " /ColSpan " + owner.span : "") + (owner.rowspan > 1 ? " /RowSpan " + owner.rowspan : "");
1352
+ perRow[owner.col] = node(owner.header ? "TH" : "TD", tattr ? " /A << /O /Table" + tattr + " >>" : "");
1353
+ attach(row, perRow[owner.col]);
1354
+ }
1355
+ return perRow[owner.col];
1356
+ }
1357
+
1358
+ var block = owner.block;
1359
+ var container = docNode;
1360
+ if (block.listId) {
1361
+ var listNode = listNodes.get(block.listId);
1362
+ if (!listNode) { listNode = node("L"); listNodes.set(block.listId, listNode); attach(docNode, listNode); }
1363
+ var itemNode = itemNodes.get(block);
1364
+ if (!itemNode) { itemNode = node("LI"); itemNodes.set(block, itemNode); attach(listNode, itemNode); }
1365
+ container = itemNode;
1366
+ }
1367
+
1368
+ if (owner.kind === "lbl") {
1369
+ var lbl = labelNodes.get(block);
1370
+ if (!lbl) { lbl = node("Lbl"); labelNodes.set(block, lbl); attach(container, lbl); }
1371
+ return lbl;
1372
+ }
1373
+
1374
+ var body = bodyNodes.get(block);
1375
+ if (!body) {
1376
+ if (block.listId) {
1377
+ body = node("LBody");
1378
+ } else {
1379
+ // A figure with no alt text is an accessibility failure, but
1380
+ // an EMPTY /Alt is a worse one: it tells assistive tech the
1381
+ // image is decorative when nobody decided that.
1382
+ var extra = block.role === "Figure"
1383
+ ? " /Alt " + pdfText(block.alt || "Image")
1384
+ : "";
1385
+ // 2026-10-15 a block in another language says so, or it is read with the document's
1386
+ // pronunciation (WCAG 3.1.2, PDF/UA 7.2). tools/pdf/paragraph-lang.mjs
1387
+ var docLang = pdfDocLang;
1388
+ var bl = String(block.lang || "").replace(/[^A-Za-z0-9-]/g, "");
1389
+ if (bl && bl.toLowerCase() !== docLang) extra += " /Lang (" + bl + ")";
1390
+ body = node(block.role || "P", extra);
1391
+ }
1392
+ bodyNodes.set(block, body);
1393
+ attach(container, body);
1394
+ }
1395
+ return body;
1396
+ }
1397
+
1398
+ // Emit. /K lists child elements and marked-content references in reading order (2026-10-17: child elements
1399
+ // used to come first, so a Link sat at the start of its paragraph). tools/pdf/link-order.mjs
1400
+ for (var n = 0; n < nodes.length; n++) {
1401
+ var nd = nodes[n];
1402
+ var kids = [];
1403
+ for (var k = 0; k < nd.seq.length; k++) {
1404
+ var sq = nd.seq[k];
1405
+ if (sq.elem) kids.push(sq.elem.id + " 0 R");
1406
+ else kids.push("<< /Type /MCR /Pg " + pageIds[sq.mcr.page] + " 0 R /MCID " + sq.mcr.mcid + " >>");
1407
+ }
1408
+ if (nd.objr) kids.push("<< /Type /OBJR /Obj " + nd.objr.id + " 0 R /Pg " + pageIds[nd.objr.page] + " 0 R >>");
1409
+ var parentRef = nd.parent ? nd.parent.id : rootId;
1410
+ pdf.set(nd.id, "<< /Type /StructElem /S /" + nd.role + " /P " + parentRef + " 0 R" + nd.extra +
1411
+ (kids.length ? " /K [" + kids.join(" ") + "]" : "") + " >>");
1412
+ }
1413
+
1414
+ // ParentTree: a number tree keyed by each page's /StructParents value,
1415
+ // whose value is the list of owning structure elements indexed by MCID.
1416
+ var numsParts = [];
1417
+ for (var pt = 0; pt < parentTree.length; pt++) {
1418
+ var arr = parentTree[pt] || [];
1419
+ var refs = [];
1420
+ for (var q = 0; q < arr.length; q++) refs.push((arr[q] || docNode.id) + " 0 R");
1421
+ numsParts.push(pt + " [" + refs.join(" ") + "]");
1422
+ }
1423
+ var parentTreeId = pdf.add("<< /Nums [" + numsParts.concat(linkKeys).join(" ") + "] >>");
1424
+
1425
+ pdf.set(rootId, "<< /Type /StructTreeRoot /K [" + docNode.id + " 0 R]" +
1426
+ " /ParentTree " + parentTreeId + " 0 R /ParentTreeNextKey " + (parentTree.length + linkKeys.length) + " >>");
1427
+
1428
+ return { rootId: rootId };
1429
+ }
1430
+
1431
+ function pdfDate(d) {
1432
+ function two(n) { return (n < 10 ? "0" : "") + n; }
1433
+ return "D:" + d.getFullYear() + two(d.getMonth() + 1) + two(d.getDate()) +
1434
+ two(d.getHours()) + two(d.getMinutes()) + two(d.getSeconds()) + "Z";
1435
+ }
1436
+ function fixed(n) { return (Math.round(n * 100) / 100).toString(); }
1437
+
1438
+ // Marked content for the structure tree.
1439
+ //
1440
+ // Tagging is what separates "a screen reader can read the words" from "a
1441
+ // screen reader knows this is a level-2 heading, followed by a 4-item list,
1442
+ // followed by a table with header cells". Section 508 and EN 301 549 both
1443
+ // require the second. Purely decorative output (rules, cell borders, the
1444
+ // running header) is marked as an Artifact instead, which removes it from
1445
+ // the reading order rather than reading "line, line, line" to the user.
1446
+ function Tagger(pageIndex) {
1447
+ this.pageIndex = pageIndex;
1448
+ this.next = 0;
1449
+ this.marks = []; // { owner, mcid } in reading order
1450
+ }
1451
+ Tagger.prototype.open = function (out, role, owner) {
1452
+ var mcid = this.next++;
1453
+ this.marks.push({ owner: owner, mcid: mcid, role: role });
1454
+ out.push("/" + role + " << /MCID " + mcid + " >> BDC");
1455
+ return mcid;
1456
+ };
1457
+ Tagger.prototype.close = function (out) { out.push("EMC"); };
1458
+ Tagger.prototype.artifact = function (out, fn) {
1459
+ out.push("/Artifact BMC");
1460
+ fn();
1461
+ out.push("EMC");
1462
+ };
1463
+
1464
+ function renderPage(ops, geom, familyKey, baseSize, imageRef, pageNumber, pageCount, options, tagger) {
1465
+ var out = [], links = [], headings = []; links.comments = []; links.seenComments = {};
1466
+ var left = geom.margin.left;
1467
+
1468
+ for (var i = 0; i < ops.length; i++) {
1469
+ var op = ops[i];
1470
+ // 2026-10-15 where each heading starts, for the bookmarks. tools/pdf/outline-bookmarks.mjs
1471
+ if (op.op === "line" && op.first && op.block && /^H[1-6]$/.test(op.block.role || "")) {
1472
+ var htext = (op.block.lines || []).map(function (ln) { return (ln.parts || []).map(function (pt) { return pt.text || ""; }).join(""); }).join(" ").replace(/\s+/g, " ").trim();
1473
+ if (htext) headings.push({ level: +op.block.role.charAt(1), title: htext, y: op.y });
1474
+ }
1475
+ if (op.op === "rule") {
1476
+ // A horizontal rule is decoration, not content.
1477
+ tagger.artifact(out, function () {
1478
+ out.push("0.8 0.8 0.8 RG 0.5 w " + fixed(left + op.indent) + " " + fixed(op.y) + " m " +
1479
+ fixed(geom.width - geom.margin.right) + " " + fixed(op.y) + " l S");
1480
+ });
1481
+ continue;
1482
+ }
1483
+ if (op.op === "row") { renderRow(op, geom, out, left, imageRef, links, tagger); continue; }
1484
+ renderLine(op, geom, out, left, familyKey, imageRef, links, tagger);
1485
+ }
1486
+
1487
+ // Running header/footer, drawn after the body so they always sit on top.
1488
+ // Both are artifacts: repeating them into the reading order on every
1489
+ // page is one of the most common findings in a PDF accessibility audit.
1490
+ var hf = headerFooter(options);
1491
+ if (hf.header || hf.footer) {
1492
+ tagger.artifact(out, function () {
1493
+ if (hf.header) drawRunning(out, hf.header, geom, geom.height - geom.margin.top * 0.55, familyKey, baseSize * 0.85, pageNumber, pageCount);
1494
+ if (hf.footer) drawRunning(out, hf.footer, geom, geom.margin.bottom * 0.55, familyKey, baseSize * 0.85, pageNumber, pageCount);
1495
+ });
1496
+ }
1497
+
1498
+ return { content: out.join("\n"), links: links, marks: tagger.marks, headings: headings, comments: links.comments };
1499
+ }
1500
+
1501
+ function headerFooter(options) {
1502
+ var setup = null;
1503
+ try { if (typeof editor.getDocumentPageSetup === "function") setup = editor.getDocumentPageSetup(); } catch (e) {}
1504
+ setup = setup || {};
1505
+ return {
1506
+ header: options.headerText || config.pdfExportHeader || plainText(setup.headerHtml) || "",
1507
+ footer: options.footerText || config.pdfExportFooter || plainText(setup.footerHtml) || ""
1508
+ };
1509
+ }
1510
+ function plainText(html) {
1511
+ if (!html || typeof html !== "string") return "";
1512
+ var d = document.createElement("div");
1513
+ d.innerHTML = __RTE_TT(html);
1514
+ return (d.textContent || "").replace(/\s+/g, " ").trim();
1515
+ }
1516
+
1517
+ function drawRunning(out, template, geom, y, familyKey, size, pageNumber, pageCount) {
1518
+ var text = String(template).replace(/\{page\}/g, pageNumber).replace(/\{total\}/g, pageCount);
1519
+ var win = toWinAnsi(text);
1520
+ var w = textWidth(win, familyKey, 0, size);
1521
+ var x = (geom.width - w) / 2;
1522
+ out.push("BT /F0 " + fixed(size) + " Tf 0.4 0.4 0.4 rg " + fixed(x) + " " + fixed(y) + " Td (" + escapeString(win) + ") Tj ET");
1523
+ }
1524
+
1525
+ function renderLine(op, geom, out, left, familyKey, imageRef, links, tagger) {
1526
+ var line = op.line, block = op.block;
1527
+ var x = left + (block.indent || 0);
1528
+ var avail = geom.width - geom.margin.right - x;
1529
+ if (block.align === "center") x += (avail - line.width) / 2;
1530
+ else if (block.align === "right") x += avail - line.width;
1531
+
1532
+ if (block.rule === "quote" && !line.image) {
1533
+ // The quote bar is decoration.
1534
+ var barX = fixed(left + block.indent - 8);
1535
+ var run = function () {
1536
+ out.push("0.8 0.8 0.85 RG 2 w " + barX + " " + fixed(op.y - line.height + 4) +
1537
+ " m " + barX + " " + fixed(op.y + line.size * 0.8) + " l S");
1538
+ };
1539
+ if (tagger) tagger.artifact(out, run); else run();
1540
+ }
1541
+
1542
+ // A list marker belongs to the FIRST line only; repeating it on a wrapped
1543
+ // continuation line turns one bullet into several.
1544
+ if (op.first && block.marker) {
1545
+ var ms = block.markerStyle || { size: line.size, color: [0, 0, 0] };
1546
+ var mw = textWidth(block.marker, familyKey, 0, ms.size);
1547
+ if (tagger) tagger.open(out, "Lbl", { kind: "lbl", block: block });
1548
+ out.push("BT /F0 " + fixed(ms.size) + " Tf " + colorOp(ms.color) + " " +
1549
+ fixed(x - mw - 6) + " " + fixed(op.y - line.size * 0.85) + " Td (" + escapeString(toWinAnsi(block.marker)) + ") Tj ET");
1550
+ if (tagger) tagger.close(out);
1551
+ }
1552
+
1553
+ // Text of the block itself. Figures carry /Alt on the structure element
1554
+ // rather than in the content stream.
1555
+ //
1556
+ // A table cell renders through this function with a SYNTHETIC block
1557
+ // (just indent/align), so the owner has to be the cell descriptor the
1558
+ // caller supplied. Passing the synthetic block instead makes every cell
1559
+ // a top-level paragraph and the table loses its structure entirely.
1560
+ var lineOwner = op.cell || { kind: "body", block: block };
1561
+ var lineOpen = false;
1562
+ if (tagger) { tagger.open(out, line.image ? "Figure" : "Span", lineOwner); lineOpen = true; }
1563
+
1564
+ // Wrapping split the text into words, but emitting one BT/Tj block per
1565
+ // WORD is wrong twice over: the content stream balloons, and text
1566
+ // extractors that infer spacing from separate show-operations can lose
1567
+ // the spaces between them, so copying a paragraph out of the PDF yields
1568
+ // "onelongrunofwords". Adjacent parts that share a font, size and colour
1569
+ // are merged back into a single run, spaces included.
1570
+ var segments = [];
1571
+ var cursor = x;
1572
+ for (var i = 0; i < line.parts.length; i++) {
1573
+ var part = line.parts[i];
1574
+ if (part.img) { segments.push({ img: part, x: cursor, w: part.w }); cursor += part.w; continue; }
1575
+ var st = part.style;
1576
+ var alias = part.uni ? "U0" : (st.mono ? "M" : "F") + part.styleIndex;
1577
+ var last = segments.length ? segments[segments.length - 1] : null;
1578
+ var mergeable = last && !last.img && last.alias === alias && last.style.size === st.size &&
1579
+ sameColor(last.style.color, st.color) && last.style.underline === st.underline &&
1580
+ last.style.strike === st.strike && last.style.link === st.link && (last.style.lang || "") === (st.lang || "") &&
1581
+ // 2026-10-21 a commented run is its own segment: merged into its neighbours it lost its note (tools/pdf/comments.mjs)
1582
+ (last.style.comment ? last.style.comment.text + "|" + last.style.comment.author : "") === (st.comment ? st.comment.text + "|" + st.comment.author : "");
1583
+ if (mergeable) { last.text += part.text; last.w += part.w; }
1584
+ else { segments.push({ text: part.text, style: st, alias: alias, x: cursor, w: part.w }); }
1585
+ cursor += part.w;
1586
+ }
1587
+
1588
+ var baseline = op.y - line.size * 0.85;
1589
+ for (var s = 0; s < segments.length; s++) {
1590
+ var seg = segments[s];
1591
+ if (seg.img) {
1592
+ var ref = imageRef(seg.img.img);
1593
+ if (ref) {
1594
+ out.push("q " + fixed(seg.w) + " 0 0 " + fixed(seg.img.h) + " " + fixed(seg.x) + " " +
1595
+ fixed(op.y - seg.img.h) + " cm /" + ref.alias + " Do Q");
1596
+ }
1597
+ continue;
1598
+ }
1599
+ // A run of pure whitespace still advanced the cursor; drawing it
1600
+ // would only add an empty operation.
1601
+ if (!seg.text || /^\s+$/.test(seg.text)) continue;
1602
+ var sst = seg.style;
1603
+ // 2026-10-17 a link's words are marked content of their own, owned by the Link element, so the Link holds
1604
+ // its text at its place in the paragraph (PDF/UA 7.18.5). tools/pdf/link-order.mjs
1605
+ var linkOwner = null;
1606
+ if (tagger && !line.image && sst.link) {
1607
+ if (lineOpen) { tagger.close(out); lineOpen = false; }
1608
+ linkOwner = { kind: "link", parent: lineOwner };
1609
+ tagger.open(out, "Span", linkOwner);
1610
+ } else if (tagger && !lineOpen) {
1611
+ tagger.open(out, "Span", lineOwner); lineOpen = true;
1612
+ }
1613
+ // 2026-10-17 a run in another language than its block (a French phrase in an English sentence) is drawn
1614
+ // inside marked content with a /Lang property, so it is read with the right pronunciation (WCAG 3.1.2,
1615
+ // PDF/UA 7.2). The paragraph's own /Lang is on its structure element. tools/pdf/inline-lang.mjs
1616
+ var runLang = String(sst.lang || "").replace(/[^A-Za-z0-9-]/g, "");
1617
+ var langMark = runLang && runLang.toLowerCase() !== String(block.lang || pdfDocLang || "").toLowerCase();
1618
+ if (langMark) out.push("/Span << /Lang (" + runLang + ") >> BDC");
1619
+ if (seg.alias === "U0") {
1620
+ out.push("BT /U0 " + fixed(sst.size) + " Tf " + colorOp(sst.color) +
1621
+ (sst.bold ? " " + colorStroke(sst.color) + " 2 Tr " + fixed(sst.size * 0.03) + " w" : "") + " " +
1622
+ (sst.italic ? "1 0 0.21 1 " + fixed(seg.x) + " " + fixed(baseline) + " Tm " : fixed(seg.x) + " " + fixed(baseline) + " Td ") +
1623
+ // the render mode outlives ET: reset it, or every later run is stroked too
1624
+ uniHex(seg.text) + " Tj" + (sst.bold ? " 0 Tr" : "") + " ET");
1625
+ } else {
1626
+ out.push("BT /" + seg.alias + " " + fixed(sst.size) + " Tf " + colorOp(sst.color) + " " +
1627
+ fixed(seg.x) + " " + fixed(baseline) + " Td (" + escapeString(toWinAnsi(seg.text)) + ") Tj ET");
1628
+ }
1629
+ if (langMark) out.push("EMC");
1630
+ if (sst.underline) {
1631
+ out.push(colorStroke(sst.color) + " 0.5 w " + fixed(seg.x) + " " + fixed(baseline - sst.size * 0.12) +
1632
+ " m " + fixed(seg.x + seg.w) + " " + fixed(baseline - sst.size * 0.12) + " l S");
1633
+ }
1634
+ if (sst.strike) {
1635
+ out.push(colorStroke(sst.color) + " 0.5 w " + fixed(seg.x) + " " + fixed(baseline + sst.size * 0.28) +
1636
+ " m " + fixed(seg.x + seg.w) + " " + fixed(baseline + sst.size * 0.28) + " l S");
1637
+ }
1638
+ if (sst.comment) {
1639
+ var cmKey = sst.comment.text + "|" + sst.comment.author;
1640
+ if (!links.seenComments[cmKey]) { links.seenComments[cmKey] = true; links.comments.push({ x0: seg.x, y0: baseline - 2, x1: seg.x + 12, y1: baseline + 10, text: sst.comment.text, author: sst.comment.author }); }
1641
+ }
1642
+ if (sst.link) {
1643
+ links.push({ href: sst.link, x0: seg.x, y0: baseline - 2, x1: seg.x + seg.w, y1: baseline + sst.size, text: seg.text || "", owner: linkOwner || lineOwner });
1644
+ }
1645
+ if (linkOwner) tagger.close(out);
1646
+ }
1647
+ if (tagger && lineOpen) tagger.close(out);
1648
+ }
1649
+
1650
+ function sameColor(a, b) {
1651
+ a = a || [0, 0, 0]; b = b || [0, 0, 0];
1652
+ return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
1653
+ }
1654
+
1655
+ function renderRow(op, geom, out, left, imageRef, links, tagger) {
1656
+ var block = op.block, row = op.row;
1657
+ var x = left + block.indent;
1658
+ var top = op.y, bottom = op.y - row.height;
1659
+
1660
+ for (var c = 0; c < row.cells.length; c++) {
1661
+ var cspan = row.cells[c].span || 1, cstart = row.cells[c].start != null ? row.cells[c].start : c;
1662
+ var cellX = x, cellW = 0;
1663
+ for (var wi = 0; wi < cstart + cspan; wi++) {
1664
+ var colw = block.colWidths && block.colWidths[wi] != null ? block.colWidths[wi] : block.colWidth;
1665
+ if (wi < cstart) cellX += colw; else cellW += colw;
1666
+ }
1667
+ // Shading and borders are decoration; only the cell TEXT belongs to
1668
+ // the reading order.
1669
+ var paint = function () {
1670
+ if (row.header) {
1671
+ out.push("0.94 0.95 0.97 rg " + fixed(cellX) + " " + fixed(bottom) + " " +
1672
+ fixed(cellW) + " " + fixed(row.height) + " re f");
1673
+ }
1674
+ out.push("0.75 0.78 0.82 RG 0.5 w " + fixed(cellX) + " " + fixed(bottom) + " " +
1675
+ fixed(cellW) + " " + fixed(row.height) + " re S");
1676
+ };
1677
+ if (tagger) tagger.artifact(out, paint); else paint();
1678
+
1679
+ // One structure element per CELL, identified by the row object and
1680
+ // column index so a table split across pages still resolves to the
1681
+ // right TD (and a repeated header row to the right TH).
1682
+ var cellOwner = { kind: "cell", block: block, row: row, col: cstart, span: cspan, rowspan: row.cells[c].rowspan || 1, header: !!row.cells[c].header };
1683
+ var cy = top - block.pad;
1684
+ var lines = row.cells[c].lines;
1685
+ for (var l = 0; l < lines.length; l++) {
1686
+ renderLine({ line: lines[l], y: cy, block: { indent: 0, align: "left" }, first: l === 0, cell: cellOwner },
1687
+ { width: cellX + cellW + geom.margin.right, margin: { right: geom.margin.right } },
1688
+ out, cellX + block.pad, "helvetica", imageRef, links, tagger);
1689
+ cy -= lines[l].height;
1690
+ }
1691
+ }
1692
+ }
1693
+
1694
+ function colorOp(c) { c = c || [0, 0, 0]; return fixed(c[0]) + " " + fixed(c[1]) + " " + fixed(c[2]) + " rg"; }
1695
+ function colorStroke(c) { c = c || [0, 0, 0]; return fixed(c[0]) + " " + fixed(c[1]) + " " + fixed(c[2]) + " RG"; }
1696
+
1697
+ // Every image is re-encoded to JPEG through a canvas. PNG could be embedded
1698
+ // as FlateDecode without re-encoding, but only for a narrow set of colour
1699
+ // types and never with an alpha channel; routing everything through one path
1700
+ // means GIF, WEBP, BMP and SVG all work rather than only some of them.
1701
+ // Transparent pixels are composited onto white, which is what the page is.
1702
+ function encodeImage(img) {
1703
+ try {
1704
+ var w = img.naturalWidth || img.width, h = img.naturalHeight || img.height;
1705
+ if (!w || !h) return null;
1706
+ var max = 2000;
1707
+ var scale = Math.min(1, max / Math.max(w, h));
1708
+ var cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(h * scale));
1709
+ var canvas = document.createElement("canvas");
1710
+ canvas.width = cw; canvas.height = ch;
1711
+ var g = canvas.getContext("2d");
1712
+ g.fillStyle = "#ffffff";
1713
+ g.fillRect(0, 0, cw, ch);
1714
+ g.drawImage(img, 0, 0, cw, ch);
1715
+ var uri = canvas.toDataURL("image/jpeg", 0.92);
1716
+ var comma = uri.indexOf(",");
1717
+ if (comma < 0) return null;
1718
+ var binary = atob(uri.slice(comma + 1));
1719
+ var bytes = new Uint8Array(binary.length);
1720
+ for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
1721
+ return { bytes: bytes, width: cw, height: ch };
1722
+ } catch (e) {
1723
+ // A cross-origin image taints the canvas. Skipping it is correct:
1724
+ // there is no way to read its pixels, and failing the whole export
1725
+ // over one decorative image would be worse.
1726
+ return null;
1727
+ }
1728
+ }
1729
+
1730
+ // ------------------------------------------------------------- downloads
1731
+ function sanitizeName(name) {
1732
+ if (!name || typeof name !== "string") return "";
1733
+ return name.replace(/\.pdf$/i, "").replace(/[\\/:*?"<>|]+/g, "").trim().slice(0, 120);
1734
+ }
1735
+ function defaultBase() {
1736
+ var d = new Date();
1737
+ function two(n) { return (n < 10 ? "0" : "") + n; }
1738
+ return "Export-" + String(d.getFullYear()).slice(2) + two(d.getMonth() + 1) + two(d.getDate()) +
1739
+ "-" + two(d.getHours()) + two(d.getMinutes()) + two(d.getSeconds());
1740
+ }
1741
+ function download(bytes, filename) {
1742
+ var blob = new Blob([bytes], { type: "application/pdf" });
1743
+ var url = URL.createObjectURL(blob);
1744
+ var a = document.createElement("a");
1745
+ a.href = url; a.download = filename;
1746
+ document.body.appendChild(a);
1747
+ a.click();
1748
+ document.body.removeChild(a);
1749
+ setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
1750
+ return filename;
1751
+ }
1752
+ }