simple-super-doc 0.11.4 → 0.11.6

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,12 +115,17 @@ 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;
119
122
  italic?: boolean;
120
123
  underline?: boolean;
121
124
  strike?: boolean;
125
+ doubleStrike?: boolean;
126
+ hidden?: boolean;
127
+ caps?: boolean;
128
+ smallCaps?: boolean;
122
129
  vertAlign?: 'super' | 'sub';
123
130
  fontSize?: number;
124
131
  fontFamily?: string;
package/dist/index.js CHANGED
@@ -85,6 +85,14 @@ function extractRPr(rPr) {
85
85
  const val = typeof st === "object" && st !== null ? st.val : void 0;
86
86
  s.strike = !(val === "0" || val === "false" || val === "off");
87
87
  }
88
+ const toggle = (node) => {
89
+ const val = typeof node === "object" && node !== null ? node.val : void 0;
90
+ return !(val === "0" || val === "false" || val === "off");
91
+ };
92
+ if ("dstrike" in rPr) s.doubleStrike = toggle(rPr.dstrike);
93
+ if ("vanish" in rPr) s.hidden = toggle(rPr.vanish);
94
+ if ("caps" in rPr) s.caps = toggle(rPr.caps);
95
+ if ("smallCaps" in rPr) s.smallCaps = toggle(rPr.smallCaps);
88
96
  if ("vertAlign" in rPr) {
89
97
  const va = rPr.vertAlign?.val;
90
98
  if (va === "superscript") s.vertAlign = "super";
@@ -168,6 +176,10 @@ function extractPPr(pPr) {
168
176
  const val = pPr.bidi?.val;
169
177
  s.rtl = !(val === "0" || val === "false" || val === "off");
170
178
  }
179
+ if ("shd" in pPr) {
180
+ const fill = pPr.shd?.fill;
181
+ if (fill && fill !== "auto") s.backgroundColor = fill;
182
+ }
171
183
  if ("pBdr" in pPr) {
172
184
  const bdr = pPr.pBdr;
173
185
  const sides = [["top", "borderTop"], ["bottom", "borderBottom"], ["left", "borderLeft"], ["right", "borderRight"]];
@@ -290,9 +302,11 @@ function parseNumbering(xml) {
290
302
  const ilvl = parseInt(String(lvl.ilvl), 10);
291
303
  const fmtNode = lvl.numFmt;
292
304
  const startNode = lvl.start;
305
+ const textNode = lvl.lvlText;
293
306
  const format = fmtNode?.val ?? "bullet";
294
307
  const start = parseInt(startNode?.val ?? "1", 10);
295
- abstractNumMap[id][ilvl] = { format, start: isNaN(start) ? 1 : start };
308
+ const text = textNode?.val;
309
+ abstractNumMap[id][ilvl] = { format, start: isNaN(start) ? 1 : start, ...text !== void 0 ? { text } : {} };
296
310
  }
297
311
  }
298
312
  const numMap = {};
@@ -465,7 +479,14 @@ function resolveListRef(pPr, ctx) {
465
479
  if (!levelInfo) return void 0;
466
480
  const start = numEntry.startOverride ?? levelInfo.start;
467
481
  const ordered = levelInfo.format !== "bullet" && levelInfo.format !== "none";
468
- return { numId, ilvl, ordered, start, format: levelInfo.format };
482
+ return {
483
+ numId,
484
+ ilvl,
485
+ ordered,
486
+ start,
487
+ format: levelInfo.format,
488
+ ...levelInfo.text !== void 0 ? { bulletText: levelInfo.text } : {}
489
+ };
469
490
  }
470
491
  async function parseRun(r, paraStyle, ctx, href) {
471
492
  const rPr = r.rPr;
@@ -482,6 +503,7 @@ async function parseRun(r, paraStyle, ctx, href) {
482
503
  if (brType === "column") return null;
483
504
  lineBreak = true;
484
505
  }
506
+ if ("cr" in r) lineBreak = true;
485
507
  const rStyleId = getVal(rPr?.rStyle);
486
508
  const charStyle = rStyleId ? ctx.styleMap[rStyleId] ?? {} : {};
487
509
  const runStyle = Object.assign({}, paraStyle, charStyle, extractRPr(rPr));
@@ -513,13 +535,22 @@ async function parseRun(r, paraStyle, ctx, href) {
513
535
  if (!rId) return null;
514
536
  const resolved = await resolveImage(rId, ctx.relationshipMap, ctx.zip);
515
537
  if (!resolved) return null;
538
+ const offsetPx = (pos) => {
539
+ const off = pos?.posOffset;
540
+ const n = off != null ? parseInt(String(off["#text"] ?? off), 10) : NaN;
541
+ return Number.isNaN(n) ? void 0 : Math.round(n / 9525);
542
+ };
543
+ const anchorXPx = anchor ? offsetPx(anchor.positionH) : void 0;
544
+ const anchorYPx = anchor ? offsetPx(anchor.positionV) : void 0;
516
545
  const img = {
517
546
  type: "image",
518
547
  src: resolved.src,
519
548
  widthPx: Math.round(cx / 9525),
520
549
  heightPx: Math.round(cy / 9525),
521
550
  ...isPageBackground ? { isPageBackground: true } : {},
522
- ...href ? { href } : {}
551
+ ...href ? { href } : {},
552
+ ...anchorXPx !== void 0 ? { anchorXPx } : {},
553
+ ...anchorYPx !== void 0 ? { anchorYPx } : {}
523
554
  };
524
555
  return img;
525
556
  }
@@ -540,27 +571,58 @@ async function parseRun(r, paraStyle, ctx, href) {
540
571
  }
541
572
  return null;
542
573
  }
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
- }
574
+ const collectText = (node) => {
575
+ if (node == null) return [];
576
+ if (Array.isArray(node)) return node.flatMap(collectText);
577
+ if (typeof node === "string") return [node];
578
+ if (typeof node === "object") {
579
+ const t = node;
580
+ return [String(t["#text"] ?? t._ ?? "")];
581
+ }
582
+ return [String(node)];
583
+ };
584
+ const segments = collectText(r.t ?? r.delText);
553
585
  const tabNode = r.tab;
554
- const tabs = Array.isArray(tabNode) ? tabNode.length : "tab" in r ? 1 : 0;
555
- const textRun = {
586
+ const tabCount = Array.isArray(tabNode) ? tabNode.length : "tab" in r ? 1 : 0;
587
+ const asArray = (n) => Array.isArray(n) ? n : n != null ? [n] : [];
588
+ let symText = "";
589
+ let symFont;
590
+ for (const sy of asArray(r.sym)) {
591
+ const code = parseInt(String(sy?.char ?? ""), 16);
592
+ if (code >= 0 && code <= 1114111) symText += String.fromCodePoint(code);
593
+ symFont ?? (symFont = sy?.font);
594
+ }
595
+ const hyphens = "\u2011".repeat(asArray(r.noBreakHyphen).length) + "\xAD".repeat(asArray(r.softHyphen).length);
596
+ if (hyphens) {
597
+ if (segments.length >= 2) segments[1] = hyphens + segments[1];
598
+ else segments[0] = (segments[0] ?? "") + hyphens;
599
+ }
600
+ if (symText) {
601
+ if (segments.length === 0) {
602
+ const style = symFont ? Object.assign({}, runStyle, { fontFamily: symFont }) : runStyle;
603
+ return {
604
+ type: "run",
605
+ text: symText,
606
+ style,
607
+ ...href ? { href } : {},
608
+ ...lineBreak ? { lineBreak: true } : {}
609
+ };
610
+ }
611
+ segments[segments.length - 1] += symText;
612
+ }
613
+ const makeRun = (text, tabs, isLast) => ({
556
614
  type: "run",
557
615
  text,
558
616
  style: runStyle,
559
617
  ...href ? { href } : {},
560
- ...lineBreak ? { lineBreak: true } : {},
618
+ ...isLast && lineBreak ? { lineBreak: true } : {},
561
619
  ...tabs ? { tabs } : {}
562
- };
563
- return textRun;
620
+ });
621
+ if (segments.length <= 1) return makeRun(segments[0] ?? "", tabCount, true);
622
+ const interleave = tabCount === segments.length - 1;
623
+ return segments.map(
624
+ (seg, i) => makeRun(seg, i === 0 ? 0 : interleave ? 1 : i === 1 ? tabCount : 0, i === segments.length - 1)
625
+ );
564
626
  }
565
627
  function resolveHyperlinkHref(hl, ctx) {
566
628
  const rId = hl.id;
@@ -677,7 +739,7 @@ async function parseParagraph(p, ctx, rawXml) {
677
739
  else {
678
740
  for (const ir of innerRuns) {
679
741
  const run2 = await parseRun(ir, paraStyle, ctx);
680
- if (run2 !== null) runs.push(run2);
742
+ if (run2 !== null) runs.push(...Array.isArray(run2) ? run2 : [run2]);
681
743
  }
682
744
  }
683
745
  continue;
@@ -705,9 +767,11 @@ async function parseParagraph(p, ctx, rawXml) {
705
767
  if (fieldStack.some((f) => f.inResult && LIVE_FIELDS.has(f.name))) continue;
706
768
  const run = await parseRun(r, paraStyle, ctx, href);
707
769
  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);
770
+ for (const rn of Array.isArray(run) ? run : [run]) {
771
+ if (deleted && rn.type === "run") rn.deleted = true;
772
+ if (inserted && rn.type === "run") rn.inserted = true;
773
+ runs.push(rn);
774
+ }
711
775
  }
712
776
  }
713
777
  const segments = [[]];
@@ -1241,8 +1305,10 @@ function styleToCss(s) {
1241
1305
  const parts = [];
1242
1306
  if (s.bold) parts.push("font-weight:bold");
1243
1307
  if (s.italic) parts.push("font-style:italic");
1244
- const deco = [s.underline ? "underline" : "", s.strike ? "line-through" : ""].filter(Boolean);
1245
- if (deco.length) parts.push(`text-decoration:${deco.join(" ")}`);
1308
+ const deco = [s.underline ? "underline" : "", s.strike || s.doubleStrike ? "line-through" : ""].filter(Boolean);
1309
+ if (deco.length) parts.push(`text-decoration:${deco.join(" ")}${s.doubleStrike && !s.underline ? " double" : ""}`);
1310
+ if (s.caps) parts.push("text-transform:uppercase");
1311
+ if (s.smallCaps) parts.push("font-variant:small-caps");
1246
1312
  if (s.vertAlign) {
1247
1313
  parts.push(`vertical-align:${s.vertAlign}`);
1248
1314
  parts.push("font-size:0.83em");
@@ -1268,16 +1334,24 @@ function renderRun(run, parent, skipLeadingTabs = false) {
1268
1334
  target = a;
1269
1335
  }
1270
1336
  if (run.type === "image") {
1337
+ const ir = run;
1271
1338
  const img = document.createElement("img");
1272
- setImageSrc(img, run.src);
1273
- img.width = run.widthPx;
1274
- img.height = run.heightPx;
1339
+ setImageSrc(img, ir.src);
1340
+ img.width = ir.widthPx;
1341
+ img.height = ir.heightPx;
1275
1342
  img.style.display = "inline-block";
1276
1343
  img.style.maxWidth = "100%";
1344
+ if (inHeaderFooter && ir.anchorXPx !== void 0) {
1345
+ img.style.position = "absolute";
1346
+ img.style.left = `${ir.anchorXPx}px`;
1347
+ img.style.top = `${ir.anchorYPx ?? 0}px`;
1348
+ img.style.maxWidth = "none";
1349
+ }
1277
1350
  target.appendChild(img);
1278
1351
  return;
1279
1352
  }
1280
1353
  const textRun = run;
1354
+ if (textRun.style.hidden) return;
1281
1355
  if (textRun.deleted) {
1282
1356
  if (!showRevisions) return;
1283
1357
  const del = document.createElement("del");
@@ -1386,6 +1460,7 @@ function ensureLineBox(el) {
1386
1460
  }
1387
1461
  }
1388
1462
  var inTableCell = false;
1463
+ var inHeaderFooter = false;
1389
1464
  var currentPageNumber = 0;
1390
1465
  var FOOTNOTE_SEPARATOR_H = 12;
1391
1466
  function footnoteNumbersIn(el) {
@@ -1434,7 +1509,10 @@ function renderFooter(doc, pageDiv, pageNum, pm, footerPx) {
1434
1509
  const el = document.createElement("div");
1435
1510
  el.className = "ssd-footer";
1436
1511
  el.style.cssText = `position:absolute;left:${pm.left}px;right:${pm.right}px;bottom:${footerPx}px`;
1512
+ const prevHF = inHeaderFooter;
1513
+ inHeaderFooter = true;
1437
1514
  renderBlocks(footer, el);
1515
+ inHeaderFooter = prevHF;
1438
1516
  pageDiv.appendChild(el);
1439
1517
  currentPageNumber = prev;
1440
1518
  }
@@ -1451,7 +1529,10 @@ function renderHeader(doc, pageDiv, pageNum, pm, headerPx) {
1451
1529
  const el = document.createElement("div");
1452
1530
  el.className = "ssd-header";
1453
1531
  el.style.cssText = `position:absolute;left:${pm.left}px;right:${pm.right}px;top:${headerPx}px`;
1532
+ const prevHF = inHeaderFooter;
1533
+ inHeaderFooter = true;
1454
1534
  renderBlocks(header, el);
1535
+ inHeaderFooter = prevHF;
1455
1536
  pageDiv.appendChild(el);
1456
1537
  currentPageNumber = prev;
1457
1538
  }
@@ -1478,8 +1559,14 @@ function renderParagraph(block) {
1478
1559
  const p = document.createElement("p");
1479
1560
  p.style.cssText = paragraphCss(block.style);
1480
1561
  if (block.style.rtl) p.dir = "rtl";
1481
- const stops = block.style.tabStops;
1562
+ let stops = block.style.tabStops;
1482
1563
  const hasTabRun = block.runs.some((r) => r.type === "run" && (r.tabs ?? 0) > 0);
1564
+ if (inHeaderFooter && hasTabRun && !stops?.some((s) => s.val === "right" || s.val === "center" || s.val === "decimal")) {
1565
+ stops = [
1566
+ { posPx: 0, val: "center", leader: "none" },
1567
+ { posPx: 0, val: "right", leader: "none" }
1568
+ ];
1569
+ }
1483
1570
  const alignStop = stops?.some((s) => s.val === "right" || s.val === "center" || s.val === "decimal");
1484
1571
  if (hasTabRun && stops && alignStop) {
1485
1572
  renderTabbedParagraph(block, p, stops);
@@ -1594,6 +1681,12 @@ var LIST_STYLE = {
1594
1681
  bullet: "disc",
1595
1682
  none: "none"
1596
1683
  };
1684
+ function listStyleTypeFor(format, bulletText) {
1685
+ if (format === "bullet" && bulletText && /^[\x20-\x7E]+$/.test(bulletText)) {
1686
+ return `"${bulletText.replace(/[\\"]/g, "\\$&")}"`;
1687
+ }
1688
+ return LIST_STYLE[format];
1689
+ }
1597
1690
  function renderBlocks(blocks, container) {
1598
1691
  let stack = [];
1599
1692
  const closeLists = () => {
@@ -1602,13 +1695,13 @@ function renderBlocks(blocks, container) {
1602
1695
  };
1603
1696
  for (const block of blocks) {
1604
1697
  if (block.type === "paragraph" && block.list) {
1605
- const { numId, ordered, start, ilvl, format } = block.list;
1698
+ const { numId, ordered, start, ilvl, format, bulletText } = block.list;
1606
1699
  if (stack.length > 0 && stack[0].numId !== numId) closeLists();
1607
1700
  while (stack.length > ilvl + 1) stack.pop();
1608
1701
  while (stack.length < ilvl + 1) {
1609
1702
  const listEl = document.createElement(ordered ? "ol" : "ul");
1610
1703
  if (ordered) listEl.start = start;
1611
- const styleType = LIST_STYLE[format];
1704
+ const styleType = listStyleTypeFor(format, bulletText);
1612
1705
  if (styleType) listEl.style.listStyleType = styleType;
1613
1706
  const indent = block.style.indentLeft ?? 24;
1614
1707
  listEl.style.margin = "0";
@@ -1721,25 +1814,29 @@ function renderPlainPaginated(doc, container, pw, ph, pm, pageOffset = 0) {
1721
1814
  for (; ; ) {
1722
1815
  pageDiv.appendChild(table);
1723
1816
  const th = table.offsetHeight;
1724
- if (used + th <= contentH - reserve) {
1817
+ const pieceRefs = footnoteNumbersIn(table);
1818
+ const pieceReserve = fnReserve(pieceRefs, pageHasFn);
1819
+ if (used + th <= contentH - reserve - pieceReserve) {
1725
1820
  used += th;
1821
+ recordFootnotes(pieceRefs, pieceReserve);
1726
1822
  break;
1727
1823
  }
1728
- const rest = splitTableRows(table, contentH - reserve - used);
1824
+ const rest = splitTableRows(table, contentH - reserve - pieceReserve - used);
1729
1825
  if (!rest) {
1730
1826
  if (used === 0) {
1731
1827
  used += th;
1828
+ recordFootnotes(pieceRefs, pieceReserve);
1732
1829
  break;
1733
1830
  }
1734
1831
  pageDiv.removeChild(table);
1735
1832
  pageDiv = newPage();
1736
1833
  continue;
1737
1834
  }
1835
+ const fitRefs = footnoteNumbersIn(table);
1836
+ recordFootnotes(fitRefs, fnReserve(fitRefs, pageHasFn));
1738
1837
  pageDiv = newPage();
1739
1838
  table = rest;
1740
1839
  }
1741
- const refs2 = footnoteNumbersIn(table);
1742
- recordFootnotes(refs2, fnReserve(refs2, pageHasFn));
1743
1840
  continue;
1744
1841
  }
1745
1842
  const h = heights[i];
@@ -2198,8 +2295,13 @@ async function resolvePart(documentXml, relationshipMap, zip, ctx, kind, parseXm
2198
2295
  }
2199
2296
  async function resolveRid(rId, relationshipMap, zip, ctx, parseXml) {
2200
2297
  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;
2298
+ const target = relationshipMap[rId].target;
2299
+ const xml = await readEntry(zip, `word/${target}`, false);
2300
+ if (!xml) return void 0;
2301
+ const base = target.replace(/^.*\//, "");
2302
+ const partRelsXml = await readEntry(zip, `word/_rels/${base}.rels`, false);
2303
+ const partCtx = partRelsXml ? { ...ctx, relationshipMap: parseRelationships(partRelsXml) } : ctx;
2304
+ return parseXml(xml, partCtx);
2203
2305
  }
2204
2306
  async function resolveNotes(xml, kind, refIds, ctx) {
2205
2307
  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.6",
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"