@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.cjs CHANGED
@@ -29,6 +29,59 @@ var import_structure2 = require("@boxpdf/reader/structure");
29
29
 
30
30
  // src/semantic-document.ts
31
31
  var import_structure = require("@boxpdf/reader/structure");
32
+
33
+ // src/semantic-inline.ts
34
+ function dominantTextColor(lines) {
35
+ const counts = /* @__PURE__ */ new Map();
36
+ for (const span of lines.flatMap((line) => line.spans)) {
37
+ const color = normalizedColor(span.color) ?? "#000000";
38
+ counts.set(color, (counts.get(color) ?? 0) + Math.max(1, [...span.text].length));
39
+ }
40
+ return [...counts].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "#000000";
41
+ }
42
+ function semanticTextHtml(text, lines, defaultColor) {
43
+ const ranges = [];
44
+ let cursor = 0;
45
+ for (const span of lines.flatMap((line) => line.spans)) {
46
+ if (!span.text) continue;
47
+ const start = text.indexOf(span.text, cursor);
48
+ if (start < 0) continue;
49
+ cursor = start + span.text.length;
50
+ const color = normalizedColor(span.color);
51
+ if (color && color !== defaultColor) ranges.push({ start, end: cursor, color });
52
+ }
53
+ const merged = mergeRanges(ranges, text);
54
+ let html = "";
55
+ let offset = 0;
56
+ for (const range of merged) {
57
+ html += escapeHtml(text.slice(offset, range.start));
58
+ html += `<span style="color:${range.color}">${escapeHtml(text.slice(range.start, range.end))}</span>`;
59
+ offset = range.end;
60
+ }
61
+ return html + escapeHtml(text.slice(offset));
62
+ }
63
+ function mergeRanges(ranges, text) {
64
+ const merged = [];
65
+ for (const range of ranges) {
66
+ const previous = merged.at(-1);
67
+ if (previous && previous.color === range.color && /^\s*$/.test(text.slice(previous.end, range.start))) {
68
+ previous.end = range.end;
69
+ } else {
70
+ merged.push({ ...range });
71
+ }
72
+ }
73
+ return merged;
74
+ }
75
+ function normalizedColor(value) {
76
+ if (!value || !/^#[\da-f]{6}$/i.test(value)) return void 0;
77
+ const color = value.toLowerCase();
78
+ return color === "#000000" || color === "#000" ? "#000000" : color;
79
+ }
80
+ function escapeHtml(value) {
81
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
82
+ }
83
+
84
+ // src/semantic-document.ts
32
85
  async function writeSemanticDocument(pages, write, lookaheadPages) {
33
86
  const stats = {
34
87
  pagesProcessed: 0,
@@ -41,6 +94,11 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
41
94
  const seenFurniture = /* @__PURE__ */ new Set();
42
95
  const sectionLevels = [];
43
96
  let activeTable;
97
+ let headerOpen = false;
98
+ let headerHasParagraph = false;
99
+ let contentStarted = false;
100
+ let employmentOpen = false;
101
+ let pendingParagraph;
44
102
  await write('<article class="pdf-semantic-document">');
45
103
  const closeTable = async () => {
46
104
  if (!activeTable) return;
@@ -53,14 +111,52 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
53
111
  sectionLevels.pop();
54
112
  }
55
113
  };
114
+ const flushPendingParagraph = async () => {
115
+ if (!pendingParagraph) return;
116
+ await write(semanticBlockHtml(pendingParagraph.block, pendingParagraph.defaultColor));
117
+ pendingParagraph = void 0;
118
+ };
119
+ const closeEmployment = async () => {
120
+ if (!employmentOpen) return;
121
+ await write("</section>");
122
+ employmentOpen = false;
123
+ };
56
124
  const emitPage = async (page, future) => {
125
+ const defaultColor = dominantTextColor(page.structured.lines);
57
126
  const futureFurniture = new Set(future.flatMap((candidate) => marginSignatures(candidate)));
58
127
  const repeatedFurniture = /* @__PURE__ */ new Set([...seenFurniture, ...futureFurniture]);
59
- for (const block of page.structured.blocks) {
128
+ for (const [blockIndex, block] of page.structured.blocks.entries()) {
129
+ const nextBlock = page.structured.blocks[blockIndex + 1];
60
130
  if (isRepeatedFurniture(block, page, repeatedFurniture)) {
61
131
  stats.suppressedFurniture += 1;
62
132
  continue;
63
133
  }
134
+ await flushPendingParagraph();
135
+ if (employmentOpen && block.type !== "list") await closeEmployment();
136
+ if (!contentStarted && !headerOpen && block.type === "heading" && block.level === 1) {
137
+ await write(`<header><h1>${semanticTextHtml(block.text, block.lines, defaultColor)}</h1>`);
138
+ headerOpen = true;
139
+ continue;
140
+ }
141
+ if (headerOpen) {
142
+ if (block.type === "paragraph") {
143
+ const tag = isContactBlock(block) ? "address" : "p";
144
+ await write(
145
+ `<${tag}>${semanticTextHtml(block.text, block.lines, defaultColor)}</${tag}>`
146
+ );
147
+ headerHasParagraph = true;
148
+ continue;
149
+ }
150
+ if (block.type === "heading" && !headerHasParagraph && (block.level === 1 || block.text.trimStart().startsWith("#") || block.level === 4 && nextBlock?.type === "paragraph" && isContactBlock(nextBlock))) {
151
+ await write(
152
+ `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${block.level}>`
153
+ );
154
+ continue;
155
+ }
156
+ await write("</header>");
157
+ headerOpen = false;
158
+ contentStarted = true;
159
+ }
64
160
  if (block.type === "table") {
65
161
  const rows = (0, import_structure.tableToRows)(block.table);
66
162
  if (activeTable && tablesContinue(activeTable.table, block.table, page.width)) {
@@ -78,16 +174,48 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
78
174
  activeTable = { table: block.table, header };
79
175
  continue;
80
176
  }
177
+ if (activeTable && block.type === "definitionList" && isFinancialSummary(block)) {
178
+ const columns = activeTable.table.columns.length;
179
+ await write(
180
+ `<tfoot>${block.entries.map((entry) => financialSummaryRow(entry, columns)).join("")}</tfoot>`
181
+ );
182
+ await closeTable();
183
+ continue;
184
+ }
81
185
  await closeTable();
82
186
  if (block.type === "heading") {
83
- await closeSections(block.level);
187
+ const level = contentStarted && block.level === 1 ? 2 : block.level;
188
+ await closeSections(level);
189
+ await write(
190
+ `<section data-level="${level}"><h${level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${level}>`
191
+ );
192
+ sectionLevels.push(level);
193
+ continue;
194
+ }
195
+ if (block.type === "paragraph") {
196
+ if (isTitledRecord(block)) {
197
+ const [institution, ...details] = block.lines;
198
+ if (institution) await write(`<h3>${escapeHtml2(institution.text)}</h3>`);
199
+ for (const detail of details) await write(`<p>${escapeHtml2(detail.text)}</p>`);
200
+ continue;
201
+ }
202
+ if (isUnmarkedList(block)) {
203
+ await write(
204
+ `<ul>${block.lines.map((line) => `<li>${escapeHtml2(line.text)}</li>`).join("")}</ul>`
205
+ );
206
+ continue;
207
+ }
208
+ pendingParagraph = { block, height: page.height, defaultColor };
209
+ continue;
210
+ }
211
+ if (block.type === "employment") {
84
212
  await write(
85
- `<section data-level="${block.level}"><h${block.level}>${escapeHtml(block.text)}</h${block.level}>`
213
+ `<section><h3>${escapeHtml2(block.role)}</h3><p>${escapeHtml2(block.organization)}</p><p>${escapeHtml2(block.date)}</p>`
86
214
  );
87
- sectionLevels.push(block.level);
215
+ employmentOpen = true;
88
216
  continue;
89
217
  }
90
- await write(semanticBlockHtml(block));
218
+ await write(semanticBlockHtml(block, defaultColor));
91
219
  }
92
220
  for (const signature of marginSignatures(page)) seenFurniture.add(signature);
93
221
  };
@@ -109,11 +237,64 @@ async function writeSemanticDocument(pages, write, lookaheadPages) {
109
237
  const ready = buffer.shift();
110
238
  if (ready) await emitPage(ready, buffer);
111
239
  }
240
+ if (headerOpen) await write("</header>");
112
241
  await closeTable();
113
- await closeSections();
242
+ await closeEmployment();
243
+ if (pendingParagraph && isFooterParagraph(pendingParagraph.block, pendingParagraph.height)) {
244
+ await closeSections();
245
+ await write(
246
+ `<footer>${semanticBlockHtml(pendingParagraph.block, pendingParagraph.defaultColor)}</footer>`
247
+ );
248
+ pendingParagraph = void 0;
249
+ } else {
250
+ await flushPendingParagraph();
251
+ await closeSections();
252
+ }
114
253
  await write("</article>");
115
254
  return stats;
116
255
  }
256
+ function isContactBlock(block) {
257
+ const text = block.text;
258
+ const signals = [
259
+ /@/.test(text),
260
+ /\+?\d[\d().\s-]{7,}/.test(text),
261
+ /\bhttps?:\/\//i.test(text),
262
+ /\b\w+\.\w{2,}\b/i.test(text)
263
+ ];
264
+ return signals.filter(Boolean).length >= 2;
265
+ }
266
+ function isTitledRecord(block) {
267
+ if (block.lines.length < 2) return false;
268
+ const [first, ...rest] = block.lines;
269
+ if (!first || rest.length === 0) return false;
270
+ const firstSize = Math.max(...first.spans.map((span) => span.fontSize));
271
+ const restSize = Math.max(...rest.flatMap((line) => line.spans.map((span) => span.fontSize)));
272
+ const emphasized = first.spans.some(
273
+ (span) => /(?:bold|semibold|demi)/i.test(span.fontFamily ?? "")
274
+ );
275
+ return emphasized || firstSize >= restSize * 1.08;
276
+ }
277
+ function isUnmarkedList(block) {
278
+ if (block.lines.length < 3) return false;
279
+ const first = block.lines[0];
280
+ if (!first) return false;
281
+ const aligned = block.lines.every(
282
+ (line) => Math.abs(line.bounds.x - first.bounds.x) <= Math.max(8, first.bounds.height)
283
+ );
284
+ const separated = block.lines.slice(1).every((line, index) => {
285
+ const previous = block.lines[index];
286
+ if (!previous) return false;
287
+ const gap = previous.bounds.y - (line.bounds.y + line.bounds.height);
288
+ return gap >= Math.min(previous.bounds.height, line.bounds.height) * 0.55;
289
+ });
290
+ return aligned && separated;
291
+ }
292
+ function isFooterParagraph(block, pageHeight) {
293
+ const inBottomMargin = block.lines.every(
294
+ (line) => line.bounds.y + line.bounds.height <= pageHeight * 0.15
295
+ );
296
+ return inBottomMargin || /^(?:thanks|thank you)\b/i.test(block.text.trim());
297
+ }
117
298
  function marginSignatures(page) {
118
299
  return page.structured.blocks.flatMap(
119
300
  (block) => blockLines(block).filter((line) => isMarginLine(line.bounds.y, line.bounds.height, page.height)).map((line) => furnitureSignature(line.text))
@@ -143,7 +324,8 @@ function tablesContinue(previous, next, pageWidth) {
143
324
  function tableHeader(rows) {
144
325
  const first = rows[0];
145
326
  if (!first) return void 0;
146
- return first.some((value) => /^(?:item|description|feature|qty|unit|amount|total)$/i.test(value)) ? first : void 0;
327
+ const later = rows.slice(1).flat();
328
+ return first.every((value) => /\p{L}/u.test(value) && !isNumericValue(value)) && later.some(isNumericValue) ? first : void 0;
147
329
  }
148
330
  function sameRow(left, right) {
149
331
  return Boolean(
@@ -152,36 +334,78 @@ function sameRow(left, right) {
152
334
  }
153
335
  function tableRow(row, header) {
154
336
  const cell = header ? "th" : "td";
155
- return `<tr>${row.map((value) => `<${cell}>${escapeHtml(value)}</${cell}>`).join("")}</tr>`;
337
+ return `<tr>${row.map((value) => `<${cell}>${escapeHtml2(value)}</${cell}>`).join("")}</tr>`;
338
+ }
339
+ function isFinancialSummary(block) {
340
+ return block.entries.length > 0 && block.entries.every((entry) => isNumericValue(entry.description)) && block.entries.some((entry) => /\p{Sc}|%/u.test(entry.description));
341
+ }
342
+ function isNumericValue(value) {
343
+ return /^(?:\p{Sc}\s*)?[\d.,'’\s]+(?:\s*%)?$/u.test(value.trim());
344
+ }
345
+ function financialSummaryRow(entry, columns) {
346
+ const colspan = columns > 2 ? ` colspan="${columns - 1}"` : "";
347
+ return `<tr><th scope="row"${colspan}>${escapeHtml2(entry.term)}</th><td>${escapeHtml2(entry.description)}</td></tr>`;
156
348
  }
157
- function semanticBlockHtml(block) {
349
+ function semanticBlockHtml(block, defaultColor = "#000000") {
158
350
  if (block.type === "heading")
159
- return `<h${block.level}>${escapeHtml(block.text)}</h${block.level}>`;
160
- if (block.type === "paragraph") return `<p>${escapeHtml(block.text)}</p>`;
351
+ return `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${block.level}>`;
352
+ if (block.type === "paragraph")
353
+ return `<p>${semanticTextHtml(block.text, block.lines, defaultColor)}</p>`;
354
+ if (block.type === "preformatted") return `<pre>${escapeHtml2(block.text)}</pre>`;
161
355
  if (block.type === "definitionList") {
162
- return `<dl>${block.entries.map(
163
- (entry) => `<div><dt>${escapeHtml(entry.term)}</dt><dd>${escapeHtml(entry.description)}</dd></div>`
356
+ 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()))) {
357
+ return block.entries.map(
358
+ (entry) => `<section><h2>${escapeHtml2(titleCase(entry.term))}</h2><p>${escapeHtml2(entry.description)}</p></section>`
359
+ ).join("");
360
+ }
361
+ const list = `<dl>${block.entries.map(
362
+ (entry) => `<div><dt>${escapeHtml2(entry.term)}</dt><dd>${escapeHtml2(entry.description)}</dd></div>`
164
363
  ).join("")}</dl>`;
364
+ return isFinancialSummary(block) ? `<section>${list}</section>` : list;
165
365
  }
166
366
  if (block.type === "cardList") {
167
- return `<div class="pdf-semantic-cards">${block.items.map(
168
- (item) => `<article><h3>${escapeHtml(item.title)}</h3>${item.details.map((detail) => `<p>${escapeHtml(detail)}</p>`).join("")}</article>`
169
- ).join("")}</div>`;
367
+ 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>`;
170
368
  }
171
369
  if (block.type === "sectionGroup") {
172
- return `<div class="pdf-semantic-sections">${block.items.map(
173
- (item) => `<section><h3>${escapeHtml(item.label)}</h3>${item.content.map((content) => `<p>${escapeHtml(content)}</p>`).join("")}</section>`
174
- ).join("")}</div>`;
370
+ return block.items.map(labeledSectionHtml).join("");
371
+ }
372
+ if (block.type === "employment") {
373
+ return `<section><h3>${escapeHtml2(block.role)}</h3><p>${escapeHtml2(block.organization)}</p><p>${escapeHtml2(block.date)}</p></section>`;
175
374
  }
176
375
  const tag = block.ordered ? "ol" : "ul";
177
- return `<${tag}>${block.items.map((item) => `<li>${escapeHtml(item.text)}</li>`).join("")}</${tag}>`;
376
+ return `<${tag}>${block.items.map((item) => `<li>${escapeHtml2(item.text)}</li>`).join("")}</${tag}>`;
377
+ }
378
+ function cardTableRow(item) {
379
+ const trailing = item.details.at(-1) ?? "";
380
+ const match = /^\s*[×x]\s*(\d+)\s+(.+)$/u.exec(trailing);
381
+ const description = item.details.slice(0, -1).join(" ");
382
+ const detail = description ? `<br><span>${escapeHtml2(description)}</span>` : "";
383
+ const quantity = match?.[1] ?? "";
384
+ const amount = match?.[2] ?? trailing;
385
+ return `<tr><th scope="row">${escapeHtml2(item.title)}${detail}</th><td>${escapeHtml2(quantity)}</td><td>${escapeHtml2(amount)}</td></tr>`;
386
+ }
387
+ function labeledSectionHtml(item) {
388
+ const heading = titleCase(item.label);
389
+ const postal = /\b(?:ship|deliver|mail)(?:ed)?\b/i.test(item.label);
390
+ if (postal) {
391
+ const [name, ...address] = item.content;
392
+ const content = [name ? `<strong>${escapeHtml2(name)}</strong>` : "", ...address.map(escapeHtml2)].filter(Boolean).join("<br>");
393
+ return `<section><h2>${escapeHtml2(heading)}</h2><address>${content}</address></section>`;
394
+ }
395
+ return `<section><h2>${escapeHtml2(heading)}</h2>${item.content.map(
396
+ (content, index) => `<p>${index === 0 ? `<strong>${escapeHtml2(content)}</strong>` : escapeHtml2(content)}</p>`
397
+ ).join("")}</section>`;
178
398
  }
179
- function escapeHtml(value) {
399
+ function titleCase(value) {
400
+ const normalized = value.trim().toLocaleLowerCase("en");
401
+ return normalized.replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase("en"));
402
+ }
403
+ function escapeHtml2(value) {
180
404
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
181
405
  }
182
406
 
183
407
  // src/index.ts
184
- 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}`;
408
+ 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}`;
185
409
  async function writeHtmlDocument(pages, write, options = {}) {
186
410
  const includeDocument = options.includeDocument ?? true;
187
411
  if (includeDocument) {
@@ -190,7 +414,7 @@ async function writeHtmlDocument(pages, write, options = {}) {
190
414
  ` lang="${escapeAttribute(options.language ?? "en")}"><head><meta charset="utf-8">`
191
415
  );
192
416
  await write('<meta name="viewport" content="width=device-width,initial-scale=1">');
193
- await write(`<title>${escapeHtml2(options.title ?? "PDF document")}</title>`);
417
+ await write(`<title>${escapeHtml3(options.title ?? "PDF document")}</title>`);
194
418
  if (options.includeStyles ?? true) await write(`<style>${styles}</style>`);
195
419
  await write("</head><body>");
196
420
  }
@@ -399,49 +623,58 @@ function positionedSpan(span, fontAliases) {
399
623
  span.fontAssetId ? fontAliases.get(span.fontAssetId) : void 0
400
624
  )
401
625
  ].join(";");
402
- return `<span class="pdf-span"${direction} style="${style}">${escapeHtml2(span.text)}</span>`;
626
+ return `<span class="pdf-span"${direction} style="${style}">${escapeHtml3(span.text)}</span>`;
403
627
  }
404
628
  async function writeFlowPage(page, write) {
405
629
  const structured = (0, import_structure2.structurePage)(page);
630
+ const defaultColor = dominantTextColor(structured.lines);
406
631
  await write(
407
632
  `<section class="pdf-page pdf-page--semantic pdf-page--flow" data-page="${page.number}">`
408
633
  );
409
634
  for (const block of structured.blocks) {
410
635
  if (block.type === "table") await write((0, import_structure2.tableToHtml)(block.table));
411
636
  else if (block.type === "heading") {
412
- await write(`<h${block.level}>${escapeHtml2(block.text)}</h${block.level}>`);
637
+ await write(
638
+ `<h${block.level}>${semanticTextHtml(block.text, block.lines, defaultColor)}</h${block.level}>`
639
+ );
413
640
  } else if (block.type === "paragraph") {
414
641
  await write(
415
- `<p${directionAttribute(block.lines.flatMap((line) => line.spans))}>${escapeHtml2(block.text)}</p>`
642
+ `<p${directionAttribute(block.lines.flatMap((line) => line.spans))}>${semanticTextHtml(block.text, block.lines, defaultColor)}</p>`
416
643
  );
644
+ } else if (block.type === "preformatted") {
645
+ await write(`<pre>${escapeHtml3(block.text)}</pre>`);
417
646
  } else if (block.type === "definitionList") {
418
647
  await write("<dl>");
419
648
  for (const entry of block.entries) {
420
649
  await write(
421
- `<div><dt>${escapeHtml2(entry.term)}</dt><dd>${escapeHtml2(entry.description)}</dd></div>`
650
+ `<div><dt>${escapeHtml3(entry.term)}</dt><dd>${escapeHtml3(entry.description)}</dd></div>`
422
651
  );
423
652
  }
424
653
  await write("</dl>");
425
654
  } else if (block.type === "cardList") {
426
655
  await write('<div class="pdf-semantic-cards">');
427
656
  for (const item of block.items) {
428
- await write(`<article><h3>${escapeHtml2(item.title)}</h3>`);
429
- for (const detail of item.details) await write(`<p>${escapeHtml2(detail)}</p>`);
657
+ await write(`<article><h3>${escapeHtml3(item.title)}</h3>`);
658
+ for (const detail of item.details) await write(`<p>${escapeHtml3(detail)}</p>`);
430
659
  await write("</article>");
431
660
  }
432
661
  await write("</div>");
433
662
  } else if (block.type === "sectionGroup") {
434
663
  await write('<div class="pdf-semantic-sections">');
435
664
  for (const item of block.items) {
436
- await write(`<section><h3>${escapeHtml2(item.label)}</h3>`);
437
- for (const content of item.content) await write(`<p>${escapeHtml2(content)}</p>`);
665
+ await write(`<section><h3>${escapeHtml3(item.label)}</h3>`);
666
+ for (const content of item.content) await write(`<p>${escapeHtml3(content)}</p>`);
438
667
  await write("</section>");
439
668
  }
440
669
  await write("</div>");
670
+ } else if (block.type === "employment") {
671
+ await write(
672
+ `<section><h3>${escapeHtml3(block.role)}</h3><p>${escapeHtml3(block.organization)}</p><p>${escapeHtml3(block.date)}</p></section>`
673
+ );
441
674
  } else {
442
675
  const tag = block.ordered ? "ol" : "ul";
443
676
  await write(`<${tag}>`);
444
- for (const item of block.items) await write(`<li>${escapeHtml2(item.text)}</li>`);
677
+ for (const item of block.items) await write(`<li>${escapeHtml3(item.text)}</li>`);
445
678
  await write(`</${tag}>`);
446
679
  }
447
680
  }
@@ -485,7 +718,7 @@ function visualText(span, pageHeight, fontAliases, counterRotateReflectedText =
485
718
  const anchorX = span.bounds.x + basisX * rtlOffset;
486
719
  const anchorY = pageHeight - span.bounds.y + basisY * rtlOffset;
487
720
  const position = transformed ? ` x="0" y="0" transform="matrix(${transform?.map(number).join(" ")} ${number(anchorX)} ${number(anchorY)})"` : ` x="${number(anchorX)}" y="${number(anchorY)}"`;
488
- return `<text${direction}${position} font-size="${number(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml2(span.text)}</text>`;
721
+ return `<text${direction}${position} font-size="${number(span.fontSize)}"${textLength}${style ? ` style="${style}"` : ""}>${escapeHtml3(span.text)}</text>`;
489
722
  }
490
723
  function isAdobeCjkFont(fontFamily) {
491
724
  return /^Adobe(?:Heiti|Song|Kaiti|Ming|Gothic|Mincho)Std-/i.test(fontFamily ?? "");
@@ -602,9 +835,9 @@ function number(value) {
602
835
  return Number.isFinite(value) ? String(Math.round(value * 1e3) / 1e3) : "0";
603
836
  }
604
837
  function escapeAttribute(value) {
605
- return escapeHtml2(value).replaceAll("`", "&#96;");
838
+ return escapeHtml3(value).replaceAll("`", "&#96;");
606
839
  }
607
- function escapeHtml2(value) {
840
+ function escapeHtml3(value) {
608
841
  return [...value].map((character) => {
609
842
  const codePoint = character.codePointAt(0) ?? 0;
610
843
  if (codePoint === 13) return "\n";