@boxpdf/html-writer 0.1.10 → 0.1.12

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/dist/index.js CHANGED
@@ -6,6 +6,59 @@ import {
6
6
  structurePage,
7
7
  tableToRows
8
8
  } from "@boxpdf/reader/structure";
9
+
10
+ // src/semantic-inline.ts
11
+ function dominantTextColor(lines) {
12
+ const counts = /* @__PURE__ */ new Map();
13
+ for (const span of lines.flatMap((line) => line.spans)) {
14
+ const color = normalizedColor(span.color) ?? "#000000";
15
+ counts.set(color, (counts.get(color) ?? 0) + Math.max(1, [...span.text].length));
16
+ }
17
+ return [...counts].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "#000000";
18
+ }
19
+ function semanticTextHtml(text, lines, defaultColor) {
20
+ const ranges = [];
21
+ let cursor = 0;
22
+ for (const span of lines.flatMap((line) => line.spans)) {
23
+ if (!span.text) continue;
24
+ const start = text.indexOf(span.text, cursor);
25
+ if (start < 0) continue;
26
+ cursor = start + span.text.length;
27
+ const color = normalizedColor(span.color);
28
+ if (color && color !== defaultColor) ranges.push({ start, end: cursor, color });
29
+ }
30
+ const merged = mergeRanges(ranges, text);
31
+ let html = "";
32
+ let offset = 0;
33
+ for (const range of merged) {
34
+ html += escapeHtml(text.slice(offset, range.start));
35
+ html += `<span style="color:${range.color}">${escapeHtml(text.slice(range.start, range.end))}</span>`;
36
+ offset = range.end;
37
+ }
38
+ return html + escapeHtml(text.slice(offset));
39
+ }
40
+ function mergeRanges(ranges, text) {
41
+ const merged = [];
42
+ for (const range of ranges) {
43
+ const previous = merged.at(-1);
44
+ if (previous && previous.color === range.color && /^\s*$/.test(text.slice(previous.end, range.start))) {
45
+ previous.end = range.end;
46
+ } else {
47
+ merged.push({ ...range });
48
+ }
49
+ }
50
+ return merged;
51
+ }
52
+ function normalizedColor(value) {
53
+ if (!value || !/^#[\da-f]{6}$/i.test(value)) return void 0;
54
+ const color = value.toLowerCase();
55
+ return color === "#000000" || color === "#000" ? "#000000" : color;
56
+ }
57
+ function escapeHtml(value) {
58
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
59
+ }
60
+
61
+ // src/semantic-document.ts
9
62
  async function writeSemanticDocument(pages, write, lookaheadPages) {
10
63
  const stats = {
11
64
  pagesProcessed: 0,
@@ -18,6 +71,11 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
18
71
  const seenFurniture = /* @__PURE__ */ new Set();
19
72
  const sectionLevels = [];
20
73
  let activeTable;
74
+ let headerOpen = false;
75
+ let headerHasParagraph = false;
76
+ let contentStarted = false;
77
+ let employmentOpen = false;
78
+ let pendingParagraph;
21
79
  await write('<article class="pdf-semantic-document">');
22
80
  const closeTable = async () => {
23
81
  if (!activeTable) return;
@@ -30,14 +88,52 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
30
88
  sectionLevels.pop();
31
89
  }
32
90
  };
91
+ const flushPendingParagraph = async () => {
92
+ if (!pendingParagraph) return;
93
+ await write(semanticBlockHtml(pendingParagraph.block, pendingParagraph.defaultColor));
94
+ pendingParagraph = void 0;
95
+ };
96
+ const closeEmployment = async () => {
97
+ if (!employmentOpen) return;
98
+ await write("</section>");
99
+ employmentOpen = false;
100
+ };
33
101
  const emitPage = async (page, future) => {
102
+ const defaultColor = dominantTextColor(page.structured.lines);
34
103
  const futureFurniture = new Set(future.flatMap((candidate) => marginSignatures(candidate)));
35
104
  const repeatedFurniture = /* @__PURE__ */ new Set([...seenFurniture, ...futureFurniture]);
36
- for (const block of page.structured.blocks) {
105
+ for (const [blockIndex, block] of page.structured.blocks.entries()) {
106
+ const nextBlock = page.structured.blocks[blockIndex + 1];
37
107
  if (isRepeatedFurniture(block, page, repeatedFurniture)) {
38
108
  stats.suppressedFurniture += 1;
39
109
  continue;
40
110
  }
111
+ await flushPendingParagraph();
112
+ if (employmentOpen && block.type !== "list") await closeEmployment();
113
+ if (!contentStarted && !headerOpen && block.type === "heading" && block.level === 1) {
114
+ await write(`<header><h1>${semanticTextHtml(block.text, block.lines, defaultColor)}</h1>`);
115
+ headerOpen = true;
116
+ continue;
117
+ }
118
+ if (headerOpen) {
119
+ if (block.type === "paragraph") {
120
+ const tag = isContactBlock(block) ? "address" : "p";
121
+ await write(
122
+ `<${tag}>${semanticTextHtml(block.text, block.lines, defaultColor)}</${tag}>`
123
+ );
124
+ headerHasParagraph = true;
125
+ continue;
126
+ }
127
+ if (block.type === "heading" && !headerHasParagraph && (block.level === 1 || block.text.trimStart().startsWith("#") || block.level === 4 && nextBlock?.type === "paragraph" && isContactBlock(nextBlock))) {
128
+ await write(
129
+ `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${block.level}>`
130
+ );
131
+ continue;
132
+ }
133
+ await write("</header>");
134
+ headerOpen = false;
135
+ contentStarted = true;
136
+ }
41
137
  if (block.type === "table") {
42
138
  const rows = tableToRows(block.table);
43
139
  if (activeTable && tablesContinue(activeTable.table, block.table, page.width)) {
@@ -55,16 +151,48 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
55
151
  activeTable = { table: block.table, header };
56
152
  continue;
57
153
  }
154
+ if (activeTable && block.type === "definitionList" && isFinancialSummary(block)) {
155
+ const columns = activeTable.table.columns.length;
156
+ await write(
157
+ `<tfoot>${block.entries.map((entry) => financialSummaryRow(entry, columns)).join("")}</tfoot>`
158
+ );
159
+ await closeTable();
160
+ continue;
161
+ }
58
162
  await closeTable();
59
163
  if (block.type === "heading") {
60
- await closeSections(block.level);
164
+ const level = contentStarted && block.level === 1 ? 2 : block.level;
165
+ await closeSections(level);
166
+ await write(
167
+ `<section data-level="${level}"><h${level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${level}>`
168
+ );
169
+ sectionLevels.push(level);
170
+ continue;
171
+ }
172
+ if (block.type === "paragraph") {
173
+ if (isTitledRecord(block)) {
174
+ const [institution, ...details] = block.lines;
175
+ if (institution) await write(`<h3>${escapeHtml2(institution.text)}</h3>`);
176
+ for (const detail of details) await write(`<p>${escapeHtml2(detail.text)}</p>`);
177
+ continue;
178
+ }
179
+ if (isUnmarkedList(block)) {
180
+ await write(
181
+ `<ul>${block.lines.map((line) => `<li>${escapeHtml2(line.text)}</li>`).join("")}</ul>`
182
+ );
183
+ continue;
184
+ }
185
+ pendingParagraph = { block, height: page.height, defaultColor };
186
+ continue;
187
+ }
188
+ if (block.type === "employment") {
61
189
  await write(
62
- `<section data-level="${block.level}"><h${block.level}>${escapeHtml(block.text)}</h${block.level}>`
190
+ `<section><h3>${escapeHtml2(block.role)}</h3><p>${escapeHtml2(block.organization)}</p><p>${escapeHtml2(block.date)}</p>`
63
191
  );
64
- sectionLevels.push(block.level);
192
+ employmentOpen = true;
65
193
  continue;
66
194
  }
67
- await write(semanticBlockHtml(block));
195
+ await write(semanticBlockHtml(block, defaultColor));
68
196
  }
69
197
  for (const signature of marginSignatures(page)) seenFurniture.add(signature);
70
198
  };
@@ -86,11 +214,64 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
86
214
  const ready = buffer.shift();
87
215
  if (ready) await emitPage(ready, buffer);
88
216
  }
217
+ if (headerOpen) await write("</header>");
89
218
  await closeTable();
90
- await closeSections();
219
+ await closeEmployment();
220
+ if (pendingParagraph && isFooterParagraph(pendingParagraph.block, pendingParagraph.height)) {
221
+ await closeSections();
222
+ await write(
223
+ `<footer>${semanticBlockHtml(pendingParagraph.block, pendingParagraph.defaultColor)}</footer>`
224
+ );
225
+ pendingParagraph = void 0;
226
+ } else {
227
+ await flushPendingParagraph();
228
+ await closeSections();
229
+ }
91
230
  await write("</article>");
92
231
  return stats;
93
232
  }
233
+ function isContactBlock(block) {
234
+ const text = block.text;
235
+ const signals = [
236
+ /@/.test(text),
237
+ /\+?\d[\d().\s-]{7,}/.test(text),
238
+ /\bhttps?:\/\//i.test(text),
239
+ /\b\w+\.\w{2,}\b/i.test(text)
240
+ ];
241
+ return signals.filter(Boolean).length >= 2;
242
+ }
243
+ function isTitledRecord(block) {
244
+ if (block.lines.length < 2) return false;
245
+ const [first, ...rest] = block.lines;
246
+ if (!first || rest.length === 0) return false;
247
+ const firstSize = Math.max(...first.spans.map((span) => span.fontSize));
248
+ const restSize = Math.max(...rest.flatMap((line) => line.spans.map((span) => span.fontSize)));
249
+ const emphasized = first.spans.some(
250
+ (span) => /(?:bold|semibold|demi)/i.test(span.fontFamily ?? "")
251
+ );
252
+ return emphasized || firstSize >= restSize * 1.08;
253
+ }
254
+ function isUnmarkedList(block) {
255
+ if (block.lines.length < 3) return false;
256
+ const first = block.lines[0];
257
+ if (!first) return false;
258
+ const aligned = block.lines.every(
259
+ (line) => Math.abs(line.bounds.x - first.bounds.x) <= Math.max(8, first.bounds.height)
260
+ );
261
+ const separated = block.lines.slice(1).every((line, index) => {
262
+ const previous = block.lines[index];
263
+ if (!previous) return false;
264
+ const gap = previous.bounds.y - (line.bounds.y + line.bounds.height);
265
+ return gap >= Math.min(previous.bounds.height, line.bounds.height) * 0.55;
266
+ });
267
+ return aligned && separated;
268
+ }
269
+ function isFooterParagraph(block, pageHeight) {
270
+ const inBottomMargin = block.lines.every(
271
+ (line) => line.bounds.y + line.bounds.height <= pageHeight * 0.15
272
+ );
273
+ return inBottomMargin || /^(?:thanks|thank you)\b/i.test(block.text.trim());
274
+ }
94
275
  function marginSignatures(page) {
95
276
  return page.structured.blocks.flatMap(
96
277
  (block) => blockLines(block).filter((line) => isMarginLine(line.bounds.y, line.bounds.height, page.height)).map((line) => furnitureSignature(line.text))
@@ -120,7 +301,8 @@ function tablesContinue(previous, next, pageWidth) {
120
301
  function tableHeader(rows) {
121
302
  const first = rows[0];
122
303
  if (!first) return void 0;
123
- return first.some((value) => /^(?:item|description|feature|qty|unit|amount|total)$/i.test(value)) ? first : void 0;
304
+ const later = rows.slice(1).flat();
305
+ return first.every((value) => /\p{L}/u.test(value) && !isNumericValue(value)) && later.some(isNumericValue) ? first : void 0;
124
306
  }
125
307
  function sameRow(left, right) {
126
308
  return Boolean(
@@ -129,36 +311,78 @@ function sameRow(left, right) {
129
311
  }
130
312
  function tableRow(row, header) {
131
313
  const cell = header ? "th" : "td";
132
- return `<tr>${row.map((value) => `<${cell}>${escapeHtml(value)}</${cell}>`).join("")}</tr>`;
314
+ return `<tr>${row.map((value) => `<${cell}>${escapeHtml2(value)}</${cell}>`).join("")}</tr>`;
315
+ }
316
+ function isFinancialSummary(block) {
317
+ return block.entries.length > 0 && block.entries.every((entry) => isNumericValue(entry.description)) && block.entries.some((entry) => /\p{Sc}|%/u.test(entry.description));
318
+ }
319
+ function isNumericValue(value) {
320
+ return /^(?:\p{Sc}\s*)?[\d.,'’\s]+(?:\s*%)?$/u.test(value.trim());
321
+ }
322
+ function financialSummaryRow(entry, columns) {
323
+ const colspan = columns > 2 ? ` colspan="${columns - 1}"` : "";
324
+ return `<tr><th scope="row"${colspan}>${escapeHtml2(entry.term)}</th><td>${escapeHtml2(entry.description)}</td></tr>`;
133
325
  }
134
- function semanticBlockHtml(block) {
326
+ function semanticBlockHtml(block, defaultColor = "#000000") {
135
327
  if (block.type === "heading")
136
- return `<h${block.level}>${escapeHtml(block.text)}</h${block.level}>`;
137
- if (block.type === "paragraph") return `<p>${escapeHtml(block.text)}</p>`;
328
+ return `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${block.level}>`;
329
+ if (block.type === "paragraph")
330
+ return `<p>${semanticTextHtml(block.text, block.lines, defaultColor)}</p>`;
331
+ if (block.type === "preformatted") return `<pre>${escapeHtml2(block.text)}</pre>`;
138
332
  if (block.type === "definitionList") {
139
- return `<dl>${block.entries.map(
140
- (entry) => `<div><dt>${escapeHtml(entry.term)}</dt><dd>${escapeHtml(entry.description)}</dd></div>`
333
+ if (block.entries.length <= 3 && block.entries.some((entry) => entry.description.trim().split(/\s+/).length >= 5) && block.entries.every((entry) => /^[A-Z][A-Z\s/-]*$/.test(entry.term.trim()))) {
334
+ return block.entries.map(
335
+ (entry) => `<section><h2>${escapeHtml2(titleCase(entry.term))}</h2><p>${escapeHtml2(entry.description)}</p></section>`
336
+ ).join("");
337
+ }
338
+ const list = `<dl>${block.entries.map(
339
+ (entry) => `<div><dt>${escapeHtml2(entry.term)}</dt><dd>${escapeHtml2(entry.description)}</dd></div>`
141
340
  ).join("")}</dl>`;
341
+ return isFinancialSummary(block) ? `<section>${list}</section>` : list;
142
342
  }
143
343
  if (block.type === "cardList") {
144
- return `<div class="pdf-semantic-cards">${block.items.map(
145
- (item) => `<article><h3>${escapeHtml(item.title)}</h3>${item.details.map((detail) => `<p>${escapeHtml(detail)}</p>`).join("")}</article>`
146
- ).join("")}</div>`;
344
+ return `<section><h2>Items ordered</h2><table><thead><tr><th scope="col">Item</th><th scope="col">Quantity</th><th scope="col">Amount</th></tr></thead><tbody>${block.items.map(cardTableRow).join("")}</tbody></table></section>`;
147
345
  }
148
346
  if (block.type === "sectionGroup") {
149
- return `<div class="pdf-semantic-sections">${block.items.map(
150
- (item) => `<section><h3>${escapeHtml(item.label)}</h3>${item.content.map((content) => `<p>${escapeHtml(content)}</p>`).join("")}</section>`
151
- ).join("")}</div>`;
347
+ return block.items.map(labeledSectionHtml).join("");
348
+ }
349
+ if (block.type === "employment") {
350
+ return `<section><h3>${escapeHtml2(block.role)}</h3><p>${escapeHtml2(block.organization)}</p><p>${escapeHtml2(block.date)}</p></section>`;
152
351
  }
153
352
  const tag = block.ordered ? "ol" : "ul";
154
- return `<${tag}>${block.items.map((item) => `<li>${escapeHtml(item.text)}</li>`).join("")}</${tag}>`;
353
+ return `<${tag}>${block.items.map((item) => `<li>${escapeHtml2(item.text)}</li>`).join("")}</${tag}>`;
354
+ }
355
+ function cardTableRow(item) {
356
+ const trailing = item.details.at(-1) ?? "";
357
+ const match = /^\s*[×x]\s*(\d+)\s+(.+)$/u.exec(trailing);
358
+ const description = item.details.slice(0, -1).join(" ");
359
+ const detail = description ? `<br><span>${escapeHtml2(description)}</span>` : "";
360
+ const quantity = match?.[1] ?? "";
361
+ const amount = match?.[2] ?? trailing;
362
+ return `<tr><th scope="row">${escapeHtml2(item.title)}${detail}</th><td>${escapeHtml2(quantity)}</td><td>${escapeHtml2(amount)}</td></tr>`;
363
+ }
364
+ function labeledSectionHtml(item) {
365
+ const heading = titleCase(item.label);
366
+ const postal = /\b(?:ship|deliver|mail)(?:ed)?\b/i.test(item.label);
367
+ if (postal) {
368
+ const [name, ...address] = item.content;
369
+ const content = [name ? `<strong>${escapeHtml2(name)}</strong>` : "", ...address.map(escapeHtml2)].filter(Boolean).join("<br>");
370
+ return `<section><h2>${escapeHtml2(heading)}</h2><address>${content}</address></section>`;
371
+ }
372
+ return `<section><h2>${escapeHtml2(heading)}</h2>${item.content.map(
373
+ (content, index) => `<p>${index === 0 ? `<strong>${escapeHtml2(content)}</strong>` : escapeHtml2(content)}</p>`
374
+ ).join("")}</section>`;
155
375
  }
156
- function escapeHtml(value) {
376
+ function titleCase(value) {
377
+ const normalized = value.trim().toLocaleLowerCase("en");
378
+ return normalized.replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase("en"));
379
+ }
380
+ function escapeHtml2(value) {
157
381
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
158
382
  }
159
383
 
160
384
  // src/index.ts
161
- var styles = `.pdf-document{margin:0 auto}.pdf-page{box-sizing:border-box;margin:1rem auto;background:#fff;color:#000}.pdf-page--visual,.pdf-page--positioned{position:relative;overflow:hidden}.pdf-page-content{position:absolute;transform-origin:0 0}.pdf-span{position:absolute;white-space:pre;transform-origin:left bottom;unicode-bidi:isolate}.pdf-span[data-direction=ttb]{writing-mode:vertical-rl}.pdf-page--semantic,.pdf-page--flow{max-width:60rem;padding:1rem}.pdf-page--semantic p,.pdf-page--flow p{white-space:pre-wrap;unicode-bidi:plaintext}.pdf-page table{border-collapse:collapse}.pdf-page td{padding:.15rem .4rem;vertical-align:top}`;
385
+ var styles = `.pdf-document{margin:0 auto}.pdf-page{box-sizing:border-box;margin:1rem auto;background:#fff;color:#000}.pdf-page--visual,.pdf-page--positioned{position:relative;overflow:hidden}.pdf-page-content{position:absolute;transform-origin:0 0}.pdf-span{position:absolute;white-space:pre;transform-origin:left bottom;unicode-bidi:isolate}.pdf-span[data-direction=ttb]{writing-mode:vertical-rl}.pdf-page--semantic,.pdf-page--flow{max-width:60rem;padding:1rem}.pdf-page--semantic p,.pdf-page--flow p{white-space:pre-wrap;unicode-bidi:plaintext}.pdf-semantic-document h1,.pdf-page--semantic h1{font-size:1.7em}.pdf-semantic-document h2,.pdf-page--semantic h2{font-size:1.5em}.pdf-semantic-document h3,.pdf-page--semantic h3{font-size:1.35em}.pdf-semantic-document h4,.pdf-page--semantic h4{font-size:1.1em}.pdf-page table{border-collapse:collapse}.pdf-page td{padding:.15rem .4rem;vertical-align:top}`;
162
386
  async function writeHtmlDocument(pages, write, options = {}) {
163
387
  const includeDocument = options.includeDocument ?? true;
164
388
  if (includeDocument) {
@@ -167,7 +391,7 @@ async function writeHtmlDocument(pages, write, options = {}) {
167
391
  ` lang="${escapeAttribute(options.language ?? "en")}"><head><meta charset="utf-8">`
168
392
  );
169
393
  await write('<meta name="viewport" content="width=device-width,initial-scale=1">');
170
- await write(`<title>${escapeHtml2(options.title ?? "PDF document")}</title>`);
394
+ await write(`<title>${escapeHtml3(options.title ?? "PDF document")}</title>`);
171
395
  if (options.includeStyles ?? true) await write(`<style>${styles}</style>`);
172
396
  await write("</head><body>");
173
397
  }
@@ -376,49 +600,58 @@ function positionedSpan(span, fontAliases) {
376
600
  span.fontAssetId ? fontAliases.get(span.fontAssetId) : void 0
377
601
  )
378
602
  ].join(";");
379
- return `<span class="pdf-span"${direction} style="${style}">${escapeHtml2(span.text)}</span>`;
603
+ return `<span class="pdf-span"${direction} style="${style}">${escapeHtml3(span.text)}</span>`;
380
604
  }
381
605
  async function writeFlowPage(page, write) {
382
606
  const structured = structurePage2(page);
607
+ const defaultColor = dominantTextColor(structured.lines);
383
608
  await write(
384
609
  `<section class="pdf-page pdf-page--semantic pdf-page--flow" data-page="${page.number}">`
385
610
  );
386
611
  for (const block of structured.blocks) {
387
612
  if (block.type === "table") await write(tableToHtml(block.table));
388
613
  else if (block.type === "heading") {
389
- await write(`<h${block.level}>${escapeHtml2(block.text)}</h${block.level}>`);
614
+ await write(
615
+ `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${block.level}>`
616
+ );
390
617
  } else if (block.type === "paragraph") {
391
618
  await write(
392
- `<p${directionAttribute(block.lines.flatMap((line) => line.spans))}>${escapeHtml2(block.text)}</p>`
619
+ `<p${directionAttribute(block.lines.flatMap((line) => line.spans))}>${semanticTextHtml(block.text, block.lines, defaultColor)}</p>`
393
620
  );
621
+ } else if (block.type === "preformatted") {
622
+ await write(`<pre>${escapeHtml3(block.text)}</pre>`);
394
623
  } else if (block.type === "definitionList") {
395
624
  await write("<dl>");
396
625
  for (const entry of block.entries) {
397
626
  await write(
398
- `<div><dt>${escapeHtml2(entry.term)}</dt><dd>${escapeHtml2(entry.description)}</dd></div>`
627
+ `<div><dt>${escapeHtml3(entry.term)}</dt><dd>${escapeHtml3(entry.description)}</dd></div>`
399
628
  );
400
629
  }
401
630
  await write("</dl>");
402
631
  } else if (block.type === "cardList") {
403
632
  await write('<div class="pdf-semantic-cards">');
404
633
  for (const item of block.items) {
405
- await write(`<article><h3>${escapeHtml2(item.title)}</h3>`);
406
- for (const detail of item.details) await write(`<p>${escapeHtml2(detail)}</p>`);
634
+ await write(`<article><h3>${escapeHtml3(item.title)}</h3>`);
635
+ for (const detail of item.details) await write(`<p>${escapeHtml3(detail)}</p>`);
407
636
  await write("</article>");
408
637
  }
409
638
  await write("</div>");
410
639
  } else if (block.type === "sectionGroup") {
411
640
  await write('<div class="pdf-semantic-sections">');
412
641
  for (const item of block.items) {
413
- await write(`<section><h3>${escapeHtml2(item.label)}</h3>`);
414
- for (const content of item.content) await write(`<p>${escapeHtml2(content)}</p>`);
642
+ await write(`<section><h3>${escapeHtml3(item.label)}</h3>`);
643
+ for (const content of item.content) await write(`<p>${escapeHtml3(content)}</p>`);
415
644
  await write("</section>");
416
645
  }
417
646
  await write("</div>");
647
+ } else if (block.type === "employment") {
648
+ await write(
649
+ `<section><h3>${escapeHtml3(block.role)}</h3><p>${escapeHtml3(block.organization)}</p><p>${escapeHtml3(block.date)}</p></section>`
650
+ );
418
651
  } else {
419
652
  const tag = block.ordered ? "ol" : "ul";
420
653
  await write(`<${tag}>`);
421
- for (const item of block.items) await write(`<li>${escapeHtml2(item.text)}</li>`);
654
+ for (const item of block.items) await write(`<li>${escapeHtml3(item.text)}</li>`);
422
655
  await write(`</${tag}>`);
423
656
  }
424
657
  }
@@ -462,7 +695,7 @@ function visualText(span, pageHeight, fontAliases, counterRotateReflectedText =
462
695
  const anchorX = span.bounds.x + basisX * rtlOffset;
463
696
  const anchorY = pageHeight - span.bounds.y + basisY * rtlOffset;
464
697
  const position = transformed ? ` x="0" y="0" transform="matrix(${transform?.map(number).join(" ")} ${number(anchorX)} ${number(anchorY)})"` : ` x="${number(anchorX)}" y="${number(anchorY)}"`;
465
- return `<text${direction}${position} font-size="${number(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml2(span.text)}</text>`;
698
+ return `<text${direction}${position} font-size="${number(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml3(span.text)}</text>`;
466
699
  }
467
700
  function isAdobeCjkFont(fontFamily) {
468
701
  return /^Adobe(?:Heiti|Song|Kaiti|Ming|Gothic|Mincho)Std-/i.test(fontFamily ?? "");
@@ -579,9 +812,9 @@ function number(value) {
579
812
  return Number.isFinite(value) ? String(Math.round(value * 1e3) / 1e3) : "0";
580
813
  }
581
814
  function escapeAttribute(value) {
582
- return escapeHtml2(value).replaceAll("`", "&#96;");
815
+ return escapeHtml3(value).replaceAll("`", "&#96;");
583
816
  }
584
- function escapeHtml2(value) {
817
+ function escapeHtml3(value) {
585
818
  return [...value].map((character) => {
586
819
  const codePoint = character.codePointAt(0) ?? 0;
587
820
  if (codePoint === 13) return "\n";