@unisim/doc 0.1.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.
- package/README.md +91 -0
- package/dist/convert.d.ts +54 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +216 -0
- package/dist/convert.js.map +1 -0
- package/dist/image.d.ts +17 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +66 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/merge.d.ts +14 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +51 -0
- package/dist/merge.js.map +1 -0
- package/dist/metrics.d.ts +19 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +151 -0
- package/dist/metrics.js.map +1 -0
- package/dist/model.d.ts +114 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +121 -0
- package/dist/model.js.map +1 -0
- package/dist/pdfcore.d.ts +118 -0
- package/dist/pdfcore.d.ts.map +1 -0
- package/dist/pdfcore.js +398 -0
- package/dist/pdfcore.js.map +1 -0
- package/dist/read/csv.d.ts +17 -0
- package/dist/read/csv.d.ts.map +1 -0
- package/dist/read/csv.js +167 -0
- package/dist/read/csv.js.map +1 -0
- package/dist/read/doc.d.ts +3 -0
- package/dist/read/doc.d.ts.map +1 -0
- package/dist/read/doc.js +360 -0
- package/dist/read/doc.js.map +1 -0
- package/dist/read/docx.d.ts +3 -0
- package/dist/read/docx.d.ts.map +1 -0
- package/dist/read/docx.js +455 -0
- package/dist/read/docx.js.map +1 -0
- package/dist/read/html.d.ts +4 -0
- package/dist/read/html.d.ts.map +1 -0
- package/dist/read/html.js +236 -0
- package/dist/read/html.js.map +1 -0
- package/dist/read/json.d.ts +9 -0
- package/dist/read/json.d.ts.map +1 -0
- package/dist/read/json.js +105 -0
- package/dist/read/json.js.map +1 -0
- package/dist/read/markdown.d.ts +4 -0
- package/dist/read/markdown.d.ts.map +1 -0
- package/dist/read/markdown.js +247 -0
- package/dist/read/markdown.js.map +1 -0
- package/dist/read/odt.d.ts +3 -0
- package/dist/read/odt.d.ts.map +1 -0
- package/dist/read/odt.js +359 -0
- package/dist/read/odt.js.map +1 -0
- package/dist/read/rtf.d.ts +3 -0
- package/dist/read/rtf.d.ts.map +1 -0
- package/dist/read/rtf.js +341 -0
- package/dist/read/rtf.js.map +1 -0
- package/dist/read/text.d.ts +4 -0
- package/dist/read/text.d.ts.map +1 -0
- package/dist/read/text.js +62 -0
- package/dist/read/text.js.map +1 -0
- package/dist/unzip.d.ts +23 -0
- package/dist/unzip.d.ts.map +1 -0
- package/dist/unzip.js +152 -0
- package/dist/unzip.js.map +1 -0
- package/dist/write/data.d.ts +16 -0
- package/dist/write/data.d.ts.map +1 -0
- package/dist/write/data.js +89 -0
- package/dist/write/data.js.map +1 -0
- package/dist/write/pdf.d.ts +10 -0
- package/dist/write/pdf.d.ts.map +1 -0
- package/dist/write/pdf.js +696 -0
- package/dist/write/pdf.js.map +1 -0
- package/dist/write/pdfSettings.d.ts +13 -0
- package/dist/write/pdfSettings.d.ts.map +1 -0
- package/dist/write/pdfSettings.js +26 -0
- package/dist/write/pdfSettings.js.map +1 -0
- package/dist/write/text.d.ts +5 -0
- package/dist/write/text.d.ts.map +1 -0
- package/dist/write/text.js +341 -0
- package/dist/write/text.js.map +1 -0
- package/dist/xml.d.ts +22 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +83 -0
- package/dist/xml.js.map +1 -0
- package/package.json +31 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// RichDoc → PDF. The layout engine.
|
|
3
|
+
//
|
|
4
|
+
// ⚠️ THIS IS A PORT. The tokenise → wrap → paginate machinery, the block
|
|
5
|
+
// renderers, the quote bar that survives a page break, the table column
|
|
6
|
+
// algorithm and the code-block chunking are all Universal PDF's
|
|
7
|
+
// `src/lib/markdownToPdf.ts`, moved onto `pdfcore.ts`'s primitives instead of
|
|
8
|
+
// pdf-lib's. Kept structurally recognisable ON PURPOSE — same function names,
|
|
9
|
+
// same order, same constants where they still apply — so a fix in either app is
|
|
10
|
+
// findable in the other. The two are not yet ONE engine in the SDK; that is in
|
|
11
|
+
// the backlog, and this comment is the reason it is worth doing.
|
|
12
|
+
//
|
|
13
|
+
// WHAT CHANGED IN THE PORT, AND WHY
|
|
14
|
+
// ---------------------------------
|
|
15
|
+
// * `font.widthOfTextAtSize()` → `widthOfText(fontId, …)`, off the AFM tables
|
|
16
|
+
// in `metrics.ts`. Same numbers; pdf-lib reads them from the same source.
|
|
17
|
+
// * Universal PDF renders MARKDOWN, so its parser and renderer are one file.
|
|
18
|
+
// Here nine formats share this, so it renders the RichDoc model and every
|
|
19
|
+
// parser lives elsewhere.
|
|
20
|
+
// * SMART QUOTES AND DASHES SURVIVE. Universal PDF flattens '’' to "'" and
|
|
21
|
+
// '—' to '-' because pdf-lib's standard fonts throw on anything outside
|
|
22
|
+
// what it will encode. WinAnsi HAS those glyphs, and `pdfcore` encodes
|
|
23
|
+
// them, so a converted Word document keeps its typography.
|
|
24
|
+
// * Hard line breaks inside a paragraph (`\n` in a run — a Word `<w:br/>`)
|
|
25
|
+
// are a token the wrapper honours. Markdown has no such thing.
|
|
26
|
+
// * Images, page breaks and a heading level 4, none of which markdown blocks
|
|
27
|
+
// produced.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
import { PdfDocument, rgb, widthOfText } from '../pdfcore.js';
|
|
30
|
+
import { MONO, SANS, SERIF } from '../metrics.js';
|
|
31
|
+
import { imageToJpeg } from '../image.js';
|
|
32
|
+
// ── Palette ──────────────────────────────────────────────────────────────────
|
|
33
|
+
// The suite's own, matching Universal PDF's markdown export so two documents
|
|
34
|
+
// converted by two apps in the suite do not look like they came from different
|
|
35
|
+
// companies.
|
|
36
|
+
const ORANGE = rgb(0.92, 0.34, 0.06);
|
|
37
|
+
const SLATE_900 = rgb(0.06, 0.09, 0.16);
|
|
38
|
+
const SLATE_700 = rgb(0.2, 0.25, 0.33);
|
|
39
|
+
const SLATE_600 = rgb(0.34, 0.39, 0.46);
|
|
40
|
+
const SLATE_400 = rgb(0.58, 0.64, 0.72);
|
|
41
|
+
const SLATE_300 = rgb(0.79, 0.82, 0.86);
|
|
42
|
+
const SLATE_200 = rgb(0.89, 0.91, 0.93);
|
|
43
|
+
const SLATE_50 = rgb(0.97, 0.98, 0.98);
|
|
44
|
+
const CODE_BG = rgb(0.97, 0.98, 1.0);
|
|
45
|
+
const CODE_BORDER = rgb(0.89, 0.92, 0.96);
|
|
46
|
+
const CODE_FG = rgb(0.78, 0.16, 0.32);
|
|
47
|
+
const LINK = rgb(0.15, 0.39, 0.92);
|
|
48
|
+
// ── Layout constants ─────────────────────────────────────────────────────────
|
|
49
|
+
const BASE_LINE = 1.45;
|
|
50
|
+
const PARA_GAP = 8;
|
|
51
|
+
const LIST_GAP = 4;
|
|
52
|
+
const CODE_BLOCK_LINE = 1.4;
|
|
53
|
+
const TABLE_PAD_X = 8;
|
|
54
|
+
const TABLE_PAD_Y = 5;
|
|
55
|
+
const BULLET_INDENT = 18;
|
|
56
|
+
const QUOTE_INDENT = 18;
|
|
57
|
+
/** Heading sizes as a multiple of the body size, so one setting scales all. */
|
|
58
|
+
const HEADING = {
|
|
59
|
+
1: { scale: 2.18, top: 18, bottom: 4, rule: true },
|
|
60
|
+
2: { scale: 1.55, top: 16, bottom: 4, rule: false },
|
|
61
|
+
3: { scale: 1.18, top: 12, bottom: 2, rule: false },
|
|
62
|
+
4: { scale: 1.0, top: 10, bottom: 2, rule: false },
|
|
63
|
+
};
|
|
64
|
+
/** Points, at 72 per inch. A4 is 210×297mm; Letter is 8.5×11in. */
|
|
65
|
+
const PAPER = {
|
|
66
|
+
A4: [595.28, 841.89],
|
|
67
|
+
Letter: [612, 792],
|
|
68
|
+
A5: [419.53, 595.28],
|
|
69
|
+
A3: [841.89, 1190.55],
|
|
70
|
+
};
|
|
71
|
+
const MARGINS = {
|
|
72
|
+
narrow: { top: 40, right: 36, bottom: 40, left: 36 },
|
|
73
|
+
normal: { top: 64, right: 56, bottom: 56, left: 56 },
|
|
74
|
+
wide: { top: 86, right: 90, bottom: 76, left: 90 },
|
|
75
|
+
};
|
|
76
|
+
// ── Entry point ──────────────────────────────────────────────────────────────
|
|
77
|
+
export async function docToPdf(doc, settings) {
|
|
78
|
+
// Images are decoded BEFORE anything is laid out, so the renderer itself
|
|
79
|
+
// stays synchronous. A renderer that awaits mid-paragraph has to make its
|
|
80
|
+
// page cursor re-entrant, and nothing about drawing a picture needs that.
|
|
81
|
+
const images = new Map();
|
|
82
|
+
for (const block of doc.blocks) {
|
|
83
|
+
if (block.kind !== 'image' || images.has(block.file))
|
|
84
|
+
continue;
|
|
85
|
+
try {
|
|
86
|
+
// 1600px cap and 0.82: the same bargain the pictures-to-PDF export makes.
|
|
87
|
+
// A photo nobody will print larger than A4 gains nothing from 4000px, and
|
|
88
|
+
// a document full of full-resolution pages is one you cannot email.
|
|
89
|
+
images.set(block.file, await imageToJpeg(block.file, 0.82, 1600));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// A corrupt or exotic embedded picture must not take the document with
|
|
93
|
+
// it — the page simply carries on without that one.
|
|
94
|
+
images.set(block.file, null);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const renderer = new Renderer(settings, images);
|
|
98
|
+
for (const block of doc.blocks)
|
|
99
|
+
renderer.renderBlock(block);
|
|
100
|
+
if (settings.pageNumbers)
|
|
101
|
+
renderer.drawPageNumbers();
|
|
102
|
+
const blob = renderer.pdf.save({
|
|
103
|
+
title: doc.title,
|
|
104
|
+
author: doc.author,
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
blob,
|
|
108
|
+
pages: renderer.pdf.pageCount,
|
|
109
|
+
dropped: [...renderer.pdf.report.dropped],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
class Renderer {
|
|
113
|
+
pdf = new PdfDocument();
|
|
114
|
+
page;
|
|
115
|
+
pageWidth = 0;
|
|
116
|
+
pageHeight = 0;
|
|
117
|
+
y = 0;
|
|
118
|
+
pages = [];
|
|
119
|
+
settings;
|
|
120
|
+
body;
|
|
121
|
+
margin;
|
|
122
|
+
images;
|
|
123
|
+
constructor(settings, images) {
|
|
124
|
+
this.settings = settings;
|
|
125
|
+
this.body = settings.font === 'serif' ? SERIF : SANS;
|
|
126
|
+
this.margin = MARGINS[settings.margin];
|
|
127
|
+
this.images = images;
|
|
128
|
+
this.newPage();
|
|
129
|
+
}
|
|
130
|
+
get base() {
|
|
131
|
+
return this.settings.fontSize;
|
|
132
|
+
}
|
|
133
|
+
get contentWidth() {
|
|
134
|
+
return this.pageWidth - this.margin.left - this.margin.right;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The paper the NEXT page opens on.
|
|
138
|
+
*
|
|
139
|
+
* Starts as the setting the caller chose and is replaced by any `pagesetup`
|
|
140
|
+
* the document carries — an imported file already knows which of its pages
|
|
141
|
+
* are landscape, and the dropdown does not.
|
|
142
|
+
*/
|
|
143
|
+
paper = null;
|
|
144
|
+
newPage() {
|
|
145
|
+
const [width, height] = this.paper ?? PAPER[this.settings.paper];
|
|
146
|
+
this.page = this.pdf.addPage(width, height);
|
|
147
|
+
this.pages.push(this.page);
|
|
148
|
+
this.pageWidth = width;
|
|
149
|
+
this.pageHeight = height;
|
|
150
|
+
this.y = height - this.margin.top;
|
|
151
|
+
}
|
|
152
|
+
/** True while nothing has been drawn on the current page. */
|
|
153
|
+
get pageIsEmpty() {
|
|
154
|
+
return this.y === this.pageHeight - this.margin.top;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Change the paper from here on.
|
|
158
|
+
*
|
|
159
|
+
* ⚠️ RESIZES an untouched page rather than adding one, and that branch is the
|
|
160
|
+
* whole reason this is not just `paper = …; newPage()`. A document whose FIRST
|
|
161
|
+
* section is landscape emits its page setup before any content, and the
|
|
162
|
+
* constructor has already opened a page — adding another would put a blank
|
|
163
|
+
* portrait sheet in front of every landscape document. Once anything is drawn
|
|
164
|
+
* the page is committed, and new paper starts a new sheet, which is also what
|
|
165
|
+
* a section break means.
|
|
166
|
+
*/
|
|
167
|
+
setPaper(width, height) {
|
|
168
|
+
if (Math.abs(this.pageWidth - width) < 0.5 && Math.abs(this.pageHeight - height) < 0.5) {
|
|
169
|
+
this.paper = [width, height];
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.paper = [width, height];
|
|
173
|
+
if (this.pageIsEmpty) {
|
|
174
|
+
this.page.setSize(width, height);
|
|
175
|
+
this.pageWidth = width;
|
|
176
|
+
this.pageHeight = height;
|
|
177
|
+
this.y = height - this.margin.top;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
this.newPage();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Start a new page if `needed` points won't fit below the cursor. */
|
|
184
|
+
ensure(needed) {
|
|
185
|
+
if (this.y - needed < this.margin.bottom)
|
|
186
|
+
this.newPage();
|
|
187
|
+
}
|
|
188
|
+
fontForRun(run) {
|
|
189
|
+
const family = run.code ? MONO : this.body;
|
|
190
|
+
if (run.bold && run.italic)
|
|
191
|
+
return family.boldItalic;
|
|
192
|
+
if (run.bold)
|
|
193
|
+
return family.bold;
|
|
194
|
+
if (run.italic)
|
|
195
|
+
return family.italic;
|
|
196
|
+
return family.regular;
|
|
197
|
+
}
|
|
198
|
+
tokenize(runs, baseSize) {
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const run of runs) {
|
|
201
|
+
const font = this.fontForRun(run);
|
|
202
|
+
// Monospaced glyphs are wider per character, so inline code set at the
|
|
203
|
+
// body size looks a size too big beside it.
|
|
204
|
+
const size = run.code ? baseSize * 0.92 : baseSize;
|
|
205
|
+
// The capture groups keep the separators, so spacing survives the split
|
|
206
|
+
// and a `\n` arrives as its own token rather than as whitespace.
|
|
207
|
+
for (const part of run.text.split(/(\n)|([ \t]+)/).filter((p) => p)) {
|
|
208
|
+
if (part === '\n') {
|
|
209
|
+
out.push({ run, text: '', width: 0, isSpace: false, isBreak: true, size, font });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
out.push({
|
|
213
|
+
run,
|
|
214
|
+
text: part,
|
|
215
|
+
width: widthOfText(font, part, size),
|
|
216
|
+
isSpace: /^[ \t]+$/.test(part),
|
|
217
|
+
isBreak: false,
|
|
218
|
+
size,
|
|
219
|
+
font,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Break a token too wide for the column.
|
|
227
|
+
*
|
|
228
|
+
* A URL or a German compound noun has no space in it, and without this the
|
|
229
|
+
* wrapper puts it on a line of its own and lets it run off the page.
|
|
230
|
+
*/
|
|
231
|
+
hardBreak(token, maxWidth) {
|
|
232
|
+
if (token.width <= maxWidth || token.isSpace || token.isBreak)
|
|
233
|
+
return [token];
|
|
234
|
+
const out = [];
|
|
235
|
+
let rest = token.text;
|
|
236
|
+
while (rest.length) {
|
|
237
|
+
let n = 1;
|
|
238
|
+
while (n < rest.length && widthOfText(token.font, rest.slice(0, n + 1), token.size) <= maxWidth)
|
|
239
|
+
n += 1;
|
|
240
|
+
const head = rest.slice(0, n);
|
|
241
|
+
out.push({ ...token, text: head, width: widthOfText(token.font, head, token.size) });
|
|
242
|
+
rest = rest.slice(n);
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
wrap(tokens, maxWidth) {
|
|
247
|
+
const broken = [];
|
|
248
|
+
for (const token of tokens)
|
|
249
|
+
broken.push(...this.hardBreak(token, maxWidth));
|
|
250
|
+
const lines = [];
|
|
251
|
+
let line = [];
|
|
252
|
+
let width = 0;
|
|
253
|
+
const endLine = () => {
|
|
254
|
+
// Trailing spaces would push the last word off a justified edge and show
|
|
255
|
+
// as a gap on a centred one.
|
|
256
|
+
while (line.length && line[line.length - 1].isSpace) {
|
|
257
|
+
width -= line.pop().width;
|
|
258
|
+
}
|
|
259
|
+
lines.push(line);
|
|
260
|
+
line = [];
|
|
261
|
+
width = 0;
|
|
262
|
+
};
|
|
263
|
+
for (const token of broken) {
|
|
264
|
+
if (token.isBreak) {
|
|
265
|
+
endLine();
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (width + token.width > maxWidth && line.length > 0) {
|
|
269
|
+
endLine();
|
|
270
|
+
// A space that fell at the wrap point is consumed by the wrap.
|
|
271
|
+
if (token.isSpace)
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
line.push(token);
|
|
275
|
+
width += token.width;
|
|
276
|
+
}
|
|
277
|
+
if (line.length)
|
|
278
|
+
lines.push(line);
|
|
279
|
+
return lines;
|
|
280
|
+
}
|
|
281
|
+
drawTokens(tokens, baseline, x, color) {
|
|
282
|
+
// Plates first, in their own pass: drawn inline they would paint over the
|
|
283
|
+
// preceding token's glyphs, because a plate is wider than its text.
|
|
284
|
+
let cursor = x;
|
|
285
|
+
for (const token of tokens) {
|
|
286
|
+
if (token.run.code && !token.isSpace) {
|
|
287
|
+
this.page.drawRect({
|
|
288
|
+
x: cursor - 1,
|
|
289
|
+
y: baseline - 2,
|
|
290
|
+
width: token.width + 2,
|
|
291
|
+
height: token.size + 4,
|
|
292
|
+
color: CODE_BG,
|
|
293
|
+
borderColor: CODE_BORDER,
|
|
294
|
+
borderWidth: 0.5,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
cursor += token.width;
|
|
298
|
+
}
|
|
299
|
+
cursor = x;
|
|
300
|
+
for (const token of tokens) {
|
|
301
|
+
const colour = token.run.code ? CODE_FG : token.run.link ? LINK : color;
|
|
302
|
+
this.page.drawText(token.text, { x: cursor, y: baseline, size: token.size, font: token.font, color: colour });
|
|
303
|
+
if (!token.isSpace && (token.run.link || token.run.underline)) {
|
|
304
|
+
this.page.drawRect({
|
|
305
|
+
x: cursor,
|
|
306
|
+
y: baseline - 1.5,
|
|
307
|
+
width: token.width,
|
|
308
|
+
height: 0.5,
|
|
309
|
+
color: token.run.link ? LINK : colour,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (!token.isSpace && token.run.strike) {
|
|
313
|
+
this.page.drawRect({
|
|
314
|
+
x: cursor,
|
|
315
|
+
y: baseline + token.size * 0.28,
|
|
316
|
+
width: token.width,
|
|
317
|
+
height: 0.5,
|
|
318
|
+
color: colour,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (token.run.link && !token.isSpace) {
|
|
322
|
+
this.page.addLink(cursor, baseline - 2, cursor + token.width, baseline + token.size, token.run.link);
|
|
323
|
+
}
|
|
324
|
+
cursor += token.width;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
drawRuns(runs, size, color, x = this.margin.left, maxWidth = this.contentWidth, lineHeightMul = BASE_LINE) {
|
|
328
|
+
const lines = this.wrap(this.tokenize(runs, size), maxWidth);
|
|
329
|
+
const lineHeight = size * lineHeightMul;
|
|
330
|
+
for (const line of lines) {
|
|
331
|
+
this.ensure(lineHeight);
|
|
332
|
+
this.y -= lineHeight;
|
|
333
|
+
this.drawTokens(line, this.y + (lineHeight - size) * 0.25, x, color);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// ── Blocks ─────────────────────────────────────────────────────────────────
|
|
337
|
+
renderBlock(block) {
|
|
338
|
+
switch (block.kind) {
|
|
339
|
+
case 'heading':
|
|
340
|
+
this.renderHeading(block.runs, block.level);
|
|
341
|
+
break;
|
|
342
|
+
case 'paragraph':
|
|
343
|
+
this.y -= PARA_GAP;
|
|
344
|
+
this.drawRuns(block.runs, this.base, SLATE_700);
|
|
345
|
+
break;
|
|
346
|
+
case 'list':
|
|
347
|
+
this.renderList(block.items, block.ordered);
|
|
348
|
+
break;
|
|
349
|
+
case 'quote':
|
|
350
|
+
this.renderQuote(block.runs);
|
|
351
|
+
break;
|
|
352
|
+
case 'code':
|
|
353
|
+
this.renderCodeBlock(block.text);
|
|
354
|
+
break;
|
|
355
|
+
case 'rule':
|
|
356
|
+
this.renderRule();
|
|
357
|
+
break;
|
|
358
|
+
case 'table':
|
|
359
|
+
this.renderTable(block.header, block.rows);
|
|
360
|
+
break;
|
|
361
|
+
case 'image':
|
|
362
|
+
this.renderImage(block.file);
|
|
363
|
+
break;
|
|
364
|
+
case 'pagebreak':
|
|
365
|
+
// Only if the page has something on it — otherwise a break at the top
|
|
366
|
+
// of a fresh page emits a blank one.
|
|
367
|
+
if (this.y < this.pageHeight - this.margin.top)
|
|
368
|
+
this.newPage();
|
|
369
|
+
break;
|
|
370
|
+
case 'pagesetup':
|
|
371
|
+
this.setPaper(block.width, block.height);
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
renderHeading(runs, level) {
|
|
376
|
+
const spec = HEADING[level];
|
|
377
|
+
const size = this.base * spec.scale;
|
|
378
|
+
this.y -= spec.top;
|
|
379
|
+
// A heading alone at the foot of a page is an orphan: this reserves the
|
|
380
|
+
// heading plus two body lines, so it moves down with the text it titles.
|
|
381
|
+
this.ensure(size * 1.2 + this.base * BASE_LINE * 2);
|
|
382
|
+
this.drawRuns(runs, size, SLATE_900, this.margin.left, this.contentWidth, 1.2);
|
|
383
|
+
if (spec.rule) {
|
|
384
|
+
this.y -= 4;
|
|
385
|
+
this.ensure(3);
|
|
386
|
+
this.page.drawRect({ x: this.margin.left, y: this.y, width: 36, height: 2.5, color: ORANGE });
|
|
387
|
+
this.page.drawRect({
|
|
388
|
+
x: this.margin.left + 36,
|
|
389
|
+
y: this.y + 0.75,
|
|
390
|
+
width: this.contentWidth - 36,
|
|
391
|
+
height: 1,
|
|
392
|
+
color: SLATE_200,
|
|
393
|
+
});
|
|
394
|
+
this.y -= 3;
|
|
395
|
+
}
|
|
396
|
+
this.y -= spec.bottom;
|
|
397
|
+
}
|
|
398
|
+
renderList(items, ordered) {
|
|
399
|
+
this.y -= PARA_GAP;
|
|
400
|
+
const lineHeight = this.base * BASE_LINE;
|
|
401
|
+
// Numbering restarts per level, so a nested list counts 1, 2, 3 of its own
|
|
402
|
+
// rather than continuing its parent's run.
|
|
403
|
+
const counters = [0, 0, 0, 0, 0];
|
|
404
|
+
items.forEach((item, index) => {
|
|
405
|
+
const level = Math.min(4, Math.max(0, item.level));
|
|
406
|
+
counters[level] += 1;
|
|
407
|
+
for (let deeper = level + 1; deeper < counters.length; deeper += 1)
|
|
408
|
+
counters[deeper] = 0;
|
|
409
|
+
const indent = BULLET_INDENT * (level + 1);
|
|
410
|
+
const x = this.margin.left + indent;
|
|
411
|
+
const maxWidth = this.contentWidth - indent;
|
|
412
|
+
const lines = this.wrap(this.tokenize(item.runs, this.base), maxWidth);
|
|
413
|
+
lines.forEach((line, lineIndex) => {
|
|
414
|
+
this.ensure(lineHeight);
|
|
415
|
+
this.y -= lineHeight;
|
|
416
|
+
const baseline = this.y + (lineHeight - this.base) * 0.25;
|
|
417
|
+
if (lineIndex === 0) {
|
|
418
|
+
// ⚠️ EVERY MARKER HERE MUST BE WINANSI. Bullets alternate by depth so
|
|
419
|
+
// a nested list reads without relying on indentation alone — but the
|
|
420
|
+
// obvious set (•, ◦, ▪) is two-thirds unwritable by a base-14 font,
|
|
421
|
+
// and the renderer emitting characters it cannot draw put a "some
|
|
422
|
+
// characters came out as ?" warning on every nested list in the app.
|
|
423
|
+
// These three are Word's own defaults and all three are in WinAnsi.
|
|
424
|
+
const marker = ordered
|
|
425
|
+
? `${counters[level]}.`
|
|
426
|
+
: level === 0 ? '•' : level === 1 ? 'o' : '-';
|
|
427
|
+
this.page.drawText(marker, {
|
|
428
|
+
x: this.margin.left + indent - BULLET_INDENT + (ordered ? 0 : 3),
|
|
429
|
+
y: baseline,
|
|
430
|
+
size: this.base,
|
|
431
|
+
font: ordered ? this.body.bold : this.body.regular,
|
|
432
|
+
color: ordered ? SLATE_600 : ORANGE,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
this.drawTokens(line, baseline, x, SLATE_700);
|
|
436
|
+
});
|
|
437
|
+
if (index < items.length - 1)
|
|
438
|
+
this.y -= LIST_GAP;
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* A quote with a bar down its left edge.
|
|
443
|
+
*
|
|
444
|
+
* The bar cannot be one rectangle: a quote that spans a page break needs one
|
|
445
|
+
* bar per page, each drawn when that page's last line is placed. Hence the
|
|
446
|
+
* segment bookkeeping — drawing it up front would put the whole bar on page
|
|
447
|
+
* one, running off the bottom.
|
|
448
|
+
*/
|
|
449
|
+
renderQuote(runs) {
|
|
450
|
+
this.y -= PARA_GAP;
|
|
451
|
+
const x = this.margin.left + QUOTE_INDENT;
|
|
452
|
+
const maxWidth = this.contentWidth - QUOTE_INDENT;
|
|
453
|
+
const lines = this.wrap(this.tokenize(runs, this.base), maxWidth);
|
|
454
|
+
if (!lines.length)
|
|
455
|
+
return;
|
|
456
|
+
const lineHeight = this.base * BASE_LINE;
|
|
457
|
+
let segmentPage = this.page;
|
|
458
|
+
let segmentTop = this.y;
|
|
459
|
+
const drawBar = (bottom) => {
|
|
460
|
+
if (segmentTop - bottom <= 0)
|
|
461
|
+
return;
|
|
462
|
+
segmentPage.drawRect({
|
|
463
|
+
x: this.margin.left,
|
|
464
|
+
y: bottom - 2,
|
|
465
|
+
width: 3,
|
|
466
|
+
height: segmentTop - bottom + 4,
|
|
467
|
+
color: ORANGE,
|
|
468
|
+
});
|
|
469
|
+
};
|
|
470
|
+
for (const line of lines) {
|
|
471
|
+
if (this.y - lineHeight < this.margin.bottom) {
|
|
472
|
+
drawBar(this.y);
|
|
473
|
+
this.newPage();
|
|
474
|
+
segmentPage = this.page;
|
|
475
|
+
segmentTop = this.y;
|
|
476
|
+
}
|
|
477
|
+
this.y -= lineHeight;
|
|
478
|
+
this.drawTokens(line, this.y + (lineHeight - this.base) * 0.25, x, SLATE_600);
|
|
479
|
+
}
|
|
480
|
+
drawBar(this.y);
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* A code block on a tinted plate.
|
|
484
|
+
*
|
|
485
|
+
* Chunked rather than drawn line by line because the plate is one rectangle
|
|
486
|
+
* per page: the loop works out how many lines fit below the cursor, draws a
|
|
487
|
+
* plate exactly that tall, fills it, and carries the rest onto the next page.
|
|
488
|
+
*/
|
|
489
|
+
renderCodeBlock(text) {
|
|
490
|
+
this.y -= PARA_GAP;
|
|
491
|
+
const size = this.base * 0.86;
|
|
492
|
+
const font = MONO.regular;
|
|
493
|
+
const pad = 10;
|
|
494
|
+
const maxWidth = this.contentWidth - 2 * pad;
|
|
495
|
+
const wrapped = [];
|
|
496
|
+
for (const raw of text.replace(/\r\n?/g, '\n').split('\n')) {
|
|
497
|
+
let rest = raw.replace(/\t/g, ' ');
|
|
498
|
+
if (widthOfText(font, rest, size) <= maxWidth) {
|
|
499
|
+
wrapped.push(rest);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
while (rest.length) {
|
|
503
|
+
let n = 1;
|
|
504
|
+
while (n < rest.length && widthOfText(font, rest.slice(0, n + 1), size) <= maxWidth)
|
|
505
|
+
n += 1;
|
|
506
|
+
wrapped.push(rest.slice(0, n));
|
|
507
|
+
rest = rest.slice(n);
|
|
508
|
+
// A continuation is indented so a wrapped line reads as one, not two.
|
|
509
|
+
if (rest.length)
|
|
510
|
+
rest = ' ' + rest;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const lineHeight = size * CODE_BLOCK_LINE;
|
|
514
|
+
let index = 0;
|
|
515
|
+
while (index < wrapped.length) {
|
|
516
|
+
const room = this.y - this.margin.bottom - 2 * pad;
|
|
517
|
+
const fits = Math.max(1, Math.floor(room / lineHeight));
|
|
518
|
+
const take = Math.min(fits, wrapped.length - index);
|
|
519
|
+
const height = take * lineHeight + 2 * pad;
|
|
520
|
+
if (this.y - height < this.margin.bottom) {
|
|
521
|
+
this.newPage();
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
this.page.drawRect({
|
|
525
|
+
x: this.margin.left,
|
|
526
|
+
y: this.y - height,
|
|
527
|
+
width: this.contentWidth,
|
|
528
|
+
height,
|
|
529
|
+
color: CODE_BG,
|
|
530
|
+
borderColor: CODE_BORDER,
|
|
531
|
+
borderWidth: 0.5,
|
|
532
|
+
});
|
|
533
|
+
let cursor = this.y - pad;
|
|
534
|
+
for (let i = 0; i < take; i += 1) {
|
|
535
|
+
cursor -= lineHeight;
|
|
536
|
+
this.page.drawText(wrapped[index + i], {
|
|
537
|
+
x: this.margin.left + pad,
|
|
538
|
+
y: cursor + lineHeight * 0.2,
|
|
539
|
+
size,
|
|
540
|
+
font,
|
|
541
|
+
color: SLATE_900,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
this.y -= height;
|
|
545
|
+
index += take;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
renderRule() {
|
|
549
|
+
this.y -= PARA_GAP;
|
|
550
|
+
this.ensure(8);
|
|
551
|
+
this.y -= 6;
|
|
552
|
+
this.page.drawRect({ x: this.margin.left, y: this.y, width: this.contentWidth, height: 0.6, color: SLATE_200 });
|
|
553
|
+
this.y -= 4;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* A picture, scaled to fit the column and never enlarged.
|
|
557
|
+
*
|
|
558
|
+
* A picture taller than the page is scaled to fit the page rather than being
|
|
559
|
+
* clipped by it — losing the bottom half of a diagram silently is the worst
|
|
560
|
+
* of the available failures.
|
|
561
|
+
*/
|
|
562
|
+
renderImage(file) {
|
|
563
|
+
const image = this.images.get(file);
|
|
564
|
+
if (!image)
|
|
565
|
+
return;
|
|
566
|
+
this.y -= PARA_GAP;
|
|
567
|
+
const maxWidth = this.contentWidth;
|
|
568
|
+
const maxHeight = this.pageHeight - this.margin.top - this.margin.bottom;
|
|
569
|
+
const scale = Math.min(1, maxWidth / image.width, maxHeight / image.height);
|
|
570
|
+
const width = image.width * scale;
|
|
571
|
+
const height = image.height * scale;
|
|
572
|
+
this.ensure(height);
|
|
573
|
+
this.y -= height;
|
|
574
|
+
this.page.drawImage(image, {
|
|
575
|
+
x: this.margin.left + (this.contentWidth - width) / 2,
|
|
576
|
+
y: this.y,
|
|
577
|
+
width,
|
|
578
|
+
height,
|
|
579
|
+
});
|
|
580
|
+
this.y -= 4;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* A table.
|
|
584
|
+
*
|
|
585
|
+
* Column widths come from two measurements per column: its NATURAL width (the
|
|
586
|
+
* cell laid out on one line) and its MINIMUM (the widest single word, which
|
|
587
|
+
* is as narrow as it can be without breaking a word mid-way). If the naturals
|
|
588
|
+
* fit, they are used and the slack is shared; if not, they are scaled down
|
|
589
|
+
* but never below the minimum, so no column collapses to nothing.
|
|
590
|
+
*/
|
|
591
|
+
renderTable(header, rows) {
|
|
592
|
+
this.y -= PARA_GAP;
|
|
593
|
+
const size = this.base * 0.9;
|
|
594
|
+
const all = header ? [header, ...rows] : [...rows];
|
|
595
|
+
const columns = Math.max(1, ...all.map((r) => r.length));
|
|
596
|
+
if (!all.length)
|
|
597
|
+
return;
|
|
598
|
+
const naturals = new Array(columns).fill(0);
|
|
599
|
+
const minimums = new Array(columns).fill(0);
|
|
600
|
+
for (const row of all) {
|
|
601
|
+
for (let c = 0; c < columns; c += 1) {
|
|
602
|
+
const tokens = this.tokenize(row[c] ?? [], size);
|
|
603
|
+
let natural = 0;
|
|
604
|
+
let widest = 0;
|
|
605
|
+
for (const token of tokens) {
|
|
606
|
+
natural += token.width;
|
|
607
|
+
if (!token.isSpace)
|
|
608
|
+
widest = Math.max(widest, token.width);
|
|
609
|
+
}
|
|
610
|
+
naturals[c] = Math.max(naturals[c], natural);
|
|
611
|
+
minimums[c] = Math.max(minimums[c], widest);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const padding = TABLE_PAD_X * 2 * columns;
|
|
615
|
+
const inner = this.contentWidth - padding;
|
|
616
|
+
const totalNatural = naturals.reduce((a, b) => a + b, 0);
|
|
617
|
+
let widths;
|
|
618
|
+
if (totalNatural <= inner) {
|
|
619
|
+
widths = naturals.map((n) => n + TABLE_PAD_X * 2);
|
|
620
|
+
const slack = this.contentWidth - widths.reduce((a, b) => a + b, 0);
|
|
621
|
+
if (slack > 0)
|
|
622
|
+
widths = widths.map((w) => w + slack / columns);
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
const scale = inner / totalNatural;
|
|
626
|
+
widths = naturals.map((n, c) => Math.max(minimums[c], n * scale) + TABLE_PAD_X * 2);
|
|
627
|
+
const total = widths.reduce((a, b) => a + b, 0);
|
|
628
|
+
if (total > this.contentWidth) {
|
|
629
|
+
const k = this.contentWidth / total;
|
|
630
|
+
widths = widths.map((w) => w * k);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const renderRow = (row, isHeader) => {
|
|
634
|
+
const cellLines = [];
|
|
635
|
+
for (let c = 0; c < columns; c += 1) {
|
|
636
|
+
const cell = (row[c] ?? []).map((r) => (isHeader ? { ...r, bold: true } : r));
|
|
637
|
+
const lines = this.wrap(this.tokenize(cell, size), widths[c] - TABLE_PAD_X * 2);
|
|
638
|
+
cellLines.push(lines.length ? lines : [[]]);
|
|
639
|
+
}
|
|
640
|
+
const lineCount = Math.max(1, ...cellLines.map((l) => l.length));
|
|
641
|
+
const lineHeight = size * 1.4;
|
|
642
|
+
const height = lineCount * lineHeight + TABLE_PAD_Y * 2;
|
|
643
|
+
this.ensure(height);
|
|
644
|
+
if (isHeader) {
|
|
645
|
+
this.page.drawRect({
|
|
646
|
+
x: this.margin.left,
|
|
647
|
+
y: this.y - height,
|
|
648
|
+
width: this.contentWidth,
|
|
649
|
+
height,
|
|
650
|
+
color: SLATE_50,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
let x = this.margin.left;
|
|
654
|
+
for (let c = 0; c < columns; c += 1) {
|
|
655
|
+
let baseline = this.y - TABLE_PAD_Y;
|
|
656
|
+
for (const line of cellLines[c]) {
|
|
657
|
+
baseline -= lineHeight;
|
|
658
|
+
this.drawTokens(line, baseline + lineHeight * 0.2, x + TABLE_PAD_X, isHeader ? SLATE_900 : SLATE_700);
|
|
659
|
+
}
|
|
660
|
+
x += widths[c];
|
|
661
|
+
}
|
|
662
|
+
this.page.drawRect({
|
|
663
|
+
x: this.margin.left,
|
|
664
|
+
y: this.y - height,
|
|
665
|
+
width: this.contentWidth,
|
|
666
|
+
height: 0.5,
|
|
667
|
+
color: SLATE_200,
|
|
668
|
+
});
|
|
669
|
+
this.y -= height;
|
|
670
|
+
};
|
|
671
|
+
this.ensure(0.5);
|
|
672
|
+
this.page.drawRect({ x: this.margin.left, y: this.y, width: this.contentWidth, height: 0.5, color: SLATE_300 });
|
|
673
|
+
if (header)
|
|
674
|
+
renderRow(header, true);
|
|
675
|
+
for (const row of rows)
|
|
676
|
+
renderRow(row, false);
|
|
677
|
+
}
|
|
678
|
+
/** Page numbers, drawn last because "of N" is not knowable until then. */
|
|
679
|
+
drawPageNumbers() {
|
|
680
|
+
const total = this.pages.length;
|
|
681
|
+
if (total < 2)
|
|
682
|
+
return;
|
|
683
|
+
for (let i = 0; i < total; i += 1) {
|
|
684
|
+
const label = `${i + 1} / ${total}`;
|
|
685
|
+
const width = widthOfText(this.body.regular, label, 9);
|
|
686
|
+
this.pages[i].drawText(label, {
|
|
687
|
+
x: this.pageWidth - this.margin.right - width,
|
|
688
|
+
y: Math.max(24, this.margin.bottom - 26),
|
|
689
|
+
size: 9,
|
|
690
|
+
font: this.body.regular,
|
|
691
|
+
color: SLATE_400,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
//# sourceMappingURL=pdf.js.map
|