simple-super-doc 0.11.4 → 0.11.5

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 CHANGED
@@ -4,6 +4,10 @@ Browser-only TypeScript library that parses a `.docx` file into a typed
4
4
  intermediate representation (IR) and renders it to HTML. Built for document
5
5
  editors and viewers that need a faithful, inspectable model of the document.
6
6
 
7
+ **▶️ [Try it live](https://rafael-t-santos.github.io/simple-super-doc/)** — drag
8
+ and drop any `.docx` in your browser to see it rendered. Nothing is uploaded;
9
+ parsing and rendering happen entirely on your machine.
10
+
7
11
  - **Typed IR** — `parse()` returns a `DocxDocument` of `Block`s (paragraphs,
8
12
  tables) with computed styles, lists, images, and page size. Inspect or
9
13
  transform it before rendering.
@@ -140,12 +144,18 @@ following are intentionally out of scope:
140
144
  ## Development
141
145
 
142
146
  ```bash
143
- npm run typecheck # tsc --noEmit
144
- npm test # vitest
145
- npm run build # esm + d.ts into dist/
146
- npm run build:demo # IIFE bundle into demo/ for the drag-and-drop demo
147
+ npm run typecheck # tsc --noEmit
148
+ npm test # vitest (node) — parser + pure layout heuristics
149
+ npm run test:browser # Playwright (headless Chromium) page-aware rendering
150
+ npm run build # esm + d.ts into dist/
151
+ npm run build:demo # IIFE bundle into demo/ for the drag-and-drop demo
147
152
  ```
148
153
 
154
+ The two-pass, page-aware pagination (table splitting, per-page footnotes) needs
155
+ a real layout engine, so it's covered by browser tests in
156
+ [`test/browser/`](test/browser/) rather than the node suite. Install the browser
157
+ once with `npx playwright-core install chromium`.
158
+
149
159
  ## License
150
160
 
151
161
  MIT
package/dist/index.d.ts CHANGED
@@ -76,6 +76,8 @@ type ImageRun = {
76
76
  heightPx: number;
77
77
  isPageBackground?: boolean;
78
78
  href?: string;
79
+ anchorXPx?: number;
80
+ anchorYPx?: number;
79
81
  };
80
82
  type TableBlock = {
81
83
  type: 'table';
@@ -113,6 +115,7 @@ type ListRef = {
113
115
  ordered: boolean;
114
116
  start: number;
115
117
  format: string;
118
+ bulletText?: string;
116
119
  };
117
120
  type ComputedStyle = {
118
121
  bold?: boolean;
package/dist/index.js CHANGED
@@ -290,9 +290,11 @@ function parseNumbering(xml) {
290
290
  const ilvl = parseInt(String(lvl.ilvl), 10);
291
291
  const fmtNode = lvl.numFmt;
292
292
  const startNode = lvl.start;
293
+ const textNode = lvl.lvlText;
293
294
  const format = fmtNode?.val ?? "bullet";
294
295
  const start = parseInt(startNode?.val ?? "1", 10);
295
- abstractNumMap[id][ilvl] = { format, start: isNaN(start) ? 1 : start };
296
+ const text = textNode?.val;
297
+ abstractNumMap[id][ilvl] = { format, start: isNaN(start) ? 1 : start, ...text !== void 0 ? { text } : {} };
296
298
  }
297
299
  }
298
300
  const numMap = {};
@@ -465,7 +467,14 @@ function resolveListRef(pPr, ctx) {
465
467
  if (!levelInfo) return void 0;
466
468
  const start = numEntry.startOverride ?? levelInfo.start;
467
469
  const ordered = levelInfo.format !== "bullet" && levelInfo.format !== "none";
468
- return { numId, ilvl, ordered, start, format: levelInfo.format };
470
+ return {
471
+ numId,
472
+ ilvl,
473
+ ordered,
474
+ start,
475
+ format: levelInfo.format,
476
+ ...levelInfo.text !== void 0 ? { bulletText: levelInfo.text } : {}
477
+ };
469
478
  }
470
479
  async function parseRun(r, paraStyle, ctx, href) {
471
480
  const rPr = r.rPr;
@@ -513,13 +522,22 @@ async function parseRun(r, paraStyle, ctx, href) {
513
522
  if (!rId) return null;
514
523
  const resolved = await resolveImage(rId, ctx.relationshipMap, ctx.zip);
515
524
  if (!resolved) return null;
525
+ const offsetPx = (pos) => {
526
+ const off = pos?.posOffset;
527
+ const n = off != null ? parseInt(String(off["#text"] ?? off), 10) : NaN;
528
+ return Number.isNaN(n) ? void 0 : Math.round(n / 9525);
529
+ };
530
+ const anchorXPx = anchor ? offsetPx(anchor.positionH) : void 0;
531
+ const anchorYPx = anchor ? offsetPx(anchor.positionV) : void 0;
516
532
  const img = {
517
533
  type: "image",
518
534
  src: resolved.src,
519
535
  widthPx: Math.round(cx / 9525),
520
536
  heightPx: Math.round(cy / 9525),
521
537
  ...isPageBackground ? { isPageBackground: true } : {},
522
- ...href ? { href } : {}
538
+ ...href ? { href } : {},
539
+ ...anchorXPx !== void 0 ? { anchorXPx } : {},
540
+ ...anchorYPx !== void 0 ? { anchorYPx } : {}
523
541
  };
524
542
  return img;
525
543
  }
@@ -540,27 +558,32 @@ async function parseRun(r, paraStyle, ctx, href) {
540
558
  }
541
559
  return null;
542
560
  }
543
- const tNode = r.t ?? r.delText;
544
- let text = "";
545
- if (typeof tNode === "string") {
546
- text = tNode;
547
- } else if (typeof tNode === "object" && tNode !== null) {
548
- const t = tNode;
549
- text = String(t["#text"] ?? t._ ?? "");
550
- } else if (tNode != null) {
551
- text = String(tNode);
552
- }
561
+ const collectText = (node) => {
562
+ if (node == null) return [];
563
+ if (Array.isArray(node)) return node.flatMap(collectText);
564
+ if (typeof node === "string") return [node];
565
+ if (typeof node === "object") {
566
+ const t = node;
567
+ return [String(t["#text"] ?? t._ ?? "")];
568
+ }
569
+ return [String(node)];
570
+ };
571
+ const segments = collectText(r.t ?? r.delText);
553
572
  const tabNode = r.tab;
554
- const tabs = Array.isArray(tabNode) ? tabNode.length : "tab" in r ? 1 : 0;
555
- const textRun = {
573
+ const tabCount = Array.isArray(tabNode) ? tabNode.length : "tab" in r ? 1 : 0;
574
+ const makeRun = (text, tabs, isLast) => ({
556
575
  type: "run",
557
576
  text,
558
577
  style: runStyle,
559
578
  ...href ? { href } : {},
560
- ...lineBreak ? { lineBreak: true } : {},
579
+ ...isLast && lineBreak ? { lineBreak: true } : {},
561
580
  ...tabs ? { tabs } : {}
562
- };
563
- return textRun;
581
+ });
582
+ if (segments.length <= 1) return makeRun(segments[0] ?? "", tabCount, true);
583
+ const interleave = tabCount === segments.length - 1;
584
+ return segments.map(
585
+ (seg, i) => makeRun(seg, i === 0 ? 0 : interleave ? 1 : i === 1 ? tabCount : 0, i === segments.length - 1)
586
+ );
564
587
  }
565
588
  function resolveHyperlinkHref(hl, ctx) {
566
589
  const rId = hl.id;
@@ -677,7 +700,7 @@ async function parseParagraph(p, ctx, rawXml) {
677
700
  else {
678
701
  for (const ir of innerRuns) {
679
702
  const run2 = await parseRun(ir, paraStyle, ctx);
680
- if (run2 !== null) runs.push(run2);
703
+ if (run2 !== null) runs.push(...Array.isArray(run2) ? run2 : [run2]);
681
704
  }
682
705
  }
683
706
  continue;
@@ -705,9 +728,11 @@ async function parseParagraph(p, ctx, rawXml) {
705
728
  if (fieldStack.some((f) => f.inResult && LIVE_FIELDS.has(f.name))) continue;
706
729
  const run = await parseRun(r, paraStyle, ctx, href);
707
730
  if (run !== null) {
708
- if (deleted && run.type === "run") run.deleted = true;
709
- if (inserted && run.type === "run") run.inserted = true;
710
- runs.push(run);
731
+ for (const rn of Array.isArray(run) ? run : [run]) {
732
+ if (deleted && rn.type === "run") rn.deleted = true;
733
+ if (inserted && rn.type === "run") rn.inserted = true;
734
+ runs.push(rn);
735
+ }
711
736
  }
712
737
  }
713
738
  const segments = [[]];
@@ -1268,12 +1293,19 @@ function renderRun(run, parent, skipLeadingTabs = false) {
1268
1293
  target = a;
1269
1294
  }
1270
1295
  if (run.type === "image") {
1296
+ const ir = run;
1271
1297
  const img = document.createElement("img");
1272
- setImageSrc(img, run.src);
1273
- img.width = run.widthPx;
1274
- img.height = run.heightPx;
1298
+ setImageSrc(img, ir.src);
1299
+ img.width = ir.widthPx;
1300
+ img.height = ir.heightPx;
1275
1301
  img.style.display = "inline-block";
1276
1302
  img.style.maxWidth = "100%";
1303
+ if (inHeaderFooter && ir.anchorXPx !== void 0) {
1304
+ img.style.position = "absolute";
1305
+ img.style.left = `${ir.anchorXPx}px`;
1306
+ img.style.top = `${ir.anchorYPx ?? 0}px`;
1307
+ img.style.maxWidth = "none";
1308
+ }
1277
1309
  target.appendChild(img);
1278
1310
  return;
1279
1311
  }
@@ -1386,6 +1418,7 @@ function ensureLineBox(el) {
1386
1418
  }
1387
1419
  }
1388
1420
  var inTableCell = false;
1421
+ var inHeaderFooter = false;
1389
1422
  var currentPageNumber = 0;
1390
1423
  var FOOTNOTE_SEPARATOR_H = 12;
1391
1424
  function footnoteNumbersIn(el) {
@@ -1434,7 +1467,10 @@ function renderFooter(doc, pageDiv, pageNum, pm, footerPx) {
1434
1467
  const el = document.createElement("div");
1435
1468
  el.className = "ssd-footer";
1436
1469
  el.style.cssText = `position:absolute;left:${pm.left}px;right:${pm.right}px;bottom:${footerPx}px`;
1470
+ const prevHF = inHeaderFooter;
1471
+ inHeaderFooter = true;
1437
1472
  renderBlocks(footer, el);
1473
+ inHeaderFooter = prevHF;
1438
1474
  pageDiv.appendChild(el);
1439
1475
  currentPageNumber = prev;
1440
1476
  }
@@ -1451,7 +1487,10 @@ function renderHeader(doc, pageDiv, pageNum, pm, headerPx) {
1451
1487
  const el = document.createElement("div");
1452
1488
  el.className = "ssd-header";
1453
1489
  el.style.cssText = `position:absolute;left:${pm.left}px;right:${pm.right}px;top:${headerPx}px`;
1490
+ const prevHF = inHeaderFooter;
1491
+ inHeaderFooter = true;
1454
1492
  renderBlocks(header, el);
1493
+ inHeaderFooter = prevHF;
1455
1494
  pageDiv.appendChild(el);
1456
1495
  currentPageNumber = prev;
1457
1496
  }
@@ -1478,8 +1517,14 @@ function renderParagraph(block) {
1478
1517
  const p = document.createElement("p");
1479
1518
  p.style.cssText = paragraphCss(block.style);
1480
1519
  if (block.style.rtl) p.dir = "rtl";
1481
- const stops = block.style.tabStops;
1520
+ let stops = block.style.tabStops;
1482
1521
  const hasTabRun = block.runs.some((r) => r.type === "run" && (r.tabs ?? 0) > 0);
1522
+ if (inHeaderFooter && hasTabRun && !stops?.some((s) => s.val === "right" || s.val === "center" || s.val === "decimal")) {
1523
+ stops = [
1524
+ { posPx: 0, val: "center", leader: "none" },
1525
+ { posPx: 0, val: "right", leader: "none" }
1526
+ ];
1527
+ }
1483
1528
  const alignStop = stops?.some((s) => s.val === "right" || s.val === "center" || s.val === "decimal");
1484
1529
  if (hasTabRun && stops && alignStop) {
1485
1530
  renderTabbedParagraph(block, p, stops);
@@ -1594,6 +1639,12 @@ var LIST_STYLE = {
1594
1639
  bullet: "disc",
1595
1640
  none: "none"
1596
1641
  };
1642
+ function listStyleTypeFor(format, bulletText) {
1643
+ if (format === "bullet" && bulletText && /^[\x20-\x7E]+$/.test(bulletText)) {
1644
+ return `"${bulletText.replace(/[\\"]/g, "\\$&")}"`;
1645
+ }
1646
+ return LIST_STYLE[format];
1647
+ }
1597
1648
  function renderBlocks(blocks, container) {
1598
1649
  let stack = [];
1599
1650
  const closeLists = () => {
@@ -1602,13 +1653,13 @@ function renderBlocks(blocks, container) {
1602
1653
  };
1603
1654
  for (const block of blocks) {
1604
1655
  if (block.type === "paragraph" && block.list) {
1605
- const { numId, ordered, start, ilvl, format } = block.list;
1656
+ const { numId, ordered, start, ilvl, format, bulletText } = block.list;
1606
1657
  if (stack.length > 0 && stack[0].numId !== numId) closeLists();
1607
1658
  while (stack.length > ilvl + 1) stack.pop();
1608
1659
  while (stack.length < ilvl + 1) {
1609
1660
  const listEl = document.createElement(ordered ? "ol" : "ul");
1610
1661
  if (ordered) listEl.start = start;
1611
- const styleType = LIST_STYLE[format];
1662
+ const styleType = listStyleTypeFor(format, bulletText);
1612
1663
  if (styleType) listEl.style.listStyleType = styleType;
1613
1664
  const indent = block.style.indentLeft ?? 24;
1614
1665
  listEl.style.margin = "0";
@@ -1721,25 +1772,29 @@ function renderPlainPaginated(doc, container, pw, ph, pm, pageOffset = 0) {
1721
1772
  for (; ; ) {
1722
1773
  pageDiv.appendChild(table);
1723
1774
  const th = table.offsetHeight;
1724
- if (used + th <= contentH - reserve) {
1775
+ const pieceRefs = footnoteNumbersIn(table);
1776
+ const pieceReserve = fnReserve(pieceRefs, pageHasFn);
1777
+ if (used + th <= contentH - reserve - pieceReserve) {
1725
1778
  used += th;
1779
+ recordFootnotes(pieceRefs, pieceReserve);
1726
1780
  break;
1727
1781
  }
1728
- const rest = splitTableRows(table, contentH - reserve - used);
1782
+ const rest = splitTableRows(table, contentH - reserve - pieceReserve - used);
1729
1783
  if (!rest) {
1730
1784
  if (used === 0) {
1731
1785
  used += th;
1786
+ recordFootnotes(pieceRefs, pieceReserve);
1732
1787
  break;
1733
1788
  }
1734
1789
  pageDiv.removeChild(table);
1735
1790
  pageDiv = newPage();
1736
1791
  continue;
1737
1792
  }
1793
+ const fitRefs = footnoteNumbersIn(table);
1794
+ recordFootnotes(fitRefs, fnReserve(fitRefs, pageHasFn));
1738
1795
  pageDiv = newPage();
1739
1796
  table = rest;
1740
1797
  }
1741
- const refs2 = footnoteNumbersIn(table);
1742
- recordFootnotes(refs2, fnReserve(refs2, pageHasFn));
1743
1798
  continue;
1744
1799
  }
1745
1800
  const h = heights[i];
@@ -2198,8 +2253,13 @@ async function resolvePart(documentXml, relationshipMap, zip, ctx, kind, parseXm
2198
2253
  }
2199
2254
  async function resolveRid(rId, relationshipMap, zip, ctx, parseXml) {
2200
2255
  if (!rId || !relationshipMap[rId]) return void 0;
2201
- const xml = await readEntry(zip, `word/${relationshipMap[rId].target}`, false);
2202
- return xml ? parseXml(xml, ctx) : void 0;
2256
+ const target = relationshipMap[rId].target;
2257
+ const xml = await readEntry(zip, `word/${target}`, false);
2258
+ if (!xml) return void 0;
2259
+ const base = target.replace(/^.*\//, "");
2260
+ const partRelsXml = await readEntry(zip, `word/_rels/${base}.rels`, false);
2261
+ const partCtx = partRelsXml ? { ...ctx, relationshipMap: parseRelationships(partRelsXml) } : ctx;
2262
+ return parseXml(xml, partCtx);
2203
2263
  }
2204
2264
  async function resolveNotes(xml, kind, refIds, ctx) {
2205
2265
  if (!xml || refIds.length === 0) return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-super-doc",
3
- "version": "0.11.4",
3
+ "version": "0.11.5",
4
4
  "description": "Faithful DOCX renderer with exposed typed IR for building document editors",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,6 +22,7 @@
22
22
  "typecheck": "tsc --noEmit",
23
23
  "test": "vitest run",
24
24
  "test:watch": "vitest",
25
+ "test:browser": "vitest run --config vitest.browser.config.ts",
25
26
  "prepublishOnly": "npm run build && npm run typecheck && npm test",
26
27
  "postversion": "git push origin HEAD --follow-tags"
27
28
  },
@@ -51,6 +52,8 @@
51
52
  },
52
53
  "devDependencies": {
53
54
  "@types/node": "^26.0.1",
55
+ "esbuild": "^0.27.7",
56
+ "playwright-core": "^1.61.1",
54
57
  "tsup": "^8.5.1",
55
58
  "typescript": "^6.0.3",
56
59
  "vitest": "^4.1.9"