@stll/folio-core 0.1.2 → 0.1.3

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.
@@ -1,6 +1,7 @@
1
1
  import { collectXmlnsDeclarations, parseXml } from "./xmlParser.js";
2
2
  import { parseWatermark } from "./watermarkParser.js";
3
3
  import { parseFooterReference, parseFooterReferences, parseHeaderReference, parseHeaderReferences } from "./headerFooterRefParser.js";
4
+ import { assignHeaderFooterVerbatimXml } from "./headerFooterVerbatim.js";
4
5
  import { parseBlockContent } from "./blockContentParser.js";
5
6
  //#region src/docx/headerFooterParser.ts
6
7
  /**
@@ -34,6 +35,7 @@ function parseHeader(headerXml, hdrFtrType = "default", styles = null, theme = n
34
35
  inHeaderFooter: true,
35
36
  rootXmlns: collectXmlnsDeclarations(rootElement)
36
37
  });
38
+ assignHeaderFooterVerbatimXml(result, headerXml);
37
39
  return result;
38
40
  }
39
41
  /**
@@ -74,6 +76,7 @@ function parseFooter(footerXml, hdrFtrType = "default", styles = null, theme = n
74
76
  inHeaderFooter: true,
75
77
  rootXmlns: collectXmlnsDeclarations(rootElement)
76
78
  });
79
+ assignHeaderFooterVerbatimXml(result, footerXml);
77
80
  return result;
78
81
  }
79
82
  /**
@@ -0,0 +1,19 @@
1
+ import { document_d_exports } from "../types/document.js";
2
+
3
+ //#region src/docx/headerFooterVerbatim.d.ts
4
+ /**
5
+ * Folio extension on {@link HeaderFooter}: original part XML captured at parse
6
+ * time so unedited headers/footers re-emit byte-identically on save (VML OLE
7
+ * wrappers, smart tags, and other constructs the model cannot fully represent).
8
+ * Cleared on first edit.
9
+ */
10
+ type HeaderFooterWithVerbatim = document_d_exports.HeaderFooter & {
11
+ verbatimXml?: string; /** Fingerprint of modeled fields at parse time; verbatim replay is safe only while it matches. */
12
+ verbatimFingerprint?: string;
13
+ };
14
+ declare const getHeaderFooterVerbatimXml: (hf: document_d_exports.HeaderFooter) => string | undefined;
15
+ declare const canReplayHeaderFooterVerbatim: (hf: document_d_exports.HeaderFooter) => boolean;
16
+ declare const assignHeaderFooterVerbatimXml: (hf: document_d_exports.HeaderFooter, xml: string) => void;
17
+ declare const clearHeaderFooterVerbatimXml: (hf: document_d_exports.HeaderFooter) => void;
18
+ //#endregion
19
+ export { HeaderFooterWithVerbatim, assignHeaderFooterVerbatimXml, canReplayHeaderFooterVerbatim, clearHeaderFooterVerbatimXml, getHeaderFooterVerbatimXml };
@@ -0,0 +1,25 @@
1
+ //#region src/docx/headerFooterVerbatim.ts
2
+ const headerFooterSerializationFingerprint = (hf) => JSON.stringify({
3
+ content: hf.content,
4
+ watermark: hf.watermark,
5
+ watermarkBlockIndex: hf.watermarkBlockIndex,
6
+ rawWatermarkXml: hf.rawWatermarkXml
7
+ });
8
+ const getHeaderFooterVerbatimXml = (hf) => hf.verbatimXml;
9
+ const canReplayHeaderFooterVerbatim = (hf) => {
10
+ const ext = hf;
11
+ if (!ext.verbatimXml || !ext.verbatimFingerprint) return false;
12
+ return ext.verbatimFingerprint === headerFooterSerializationFingerprint(hf);
13
+ };
14
+ const assignHeaderFooterVerbatimXml = (hf, xml) => {
15
+ const ext = hf;
16
+ ext.verbatimXml = xml;
17
+ ext.verbatimFingerprint = headerFooterSerializationFingerprint(hf);
18
+ };
19
+ const clearHeaderFooterVerbatimXml = (hf) => {
20
+ const ext = hf;
21
+ delete ext.verbatimXml;
22
+ delete ext.verbatimFingerprint;
23
+ };
24
+ //#endregion
25
+ export { assignHeaderFooterVerbatimXml, canReplayHeaderFooterVerbatim, clearHeaderFooterVerbatimXml, getHeaderFooterVerbatimXml };
@@ -0,0 +1,30 @@
1
+ //#region src/docx/metafileRaster.d.ts
2
+ /**
3
+ * Extract a browser-renderable raster (PNG/JPEG) embedded inside an
4
+ * EMF/WMF metafile.
5
+ *
6
+ * Word frequently stores header logos / OLE preview pictures as EMF — a
7
+ * Windows GDI metafile browsers cannot decode. In practice such an EMF almost
8
+ * always carries the actual artwork as a single embedded PNG or JPEG (an
9
+ * `EmfPlusObject` bitmap record, or a `StretchDIBits` payload). Rather than
10
+ * implement a GDI renderer, this scans the byte stream for a raster signature,
11
+ * brackets it to its container's end marker, and returns the slice. The caller
12
+ * uses it as the media entry's `dataUrl` so `<img>` just works; the original
13
+ * EMF bytes stay on `MediaFile.data` for round-trip.
14
+ *
15
+ * When no embedded raster is found this returns `null`; callers fall back to a
16
+ * sized placeholder and/or the host-supplied `mediaResolver` hook.
17
+ */
18
+ type ExtractedRaster = {
19
+ bytes: Uint8Array;
20
+ mimeType: "image/png" | "image/jpeg";
21
+ };
22
+ /**
23
+ * Scan a metafile (EMF or WMF) for an embedded browser-renderable raster.
24
+ * Returns the first PNG, else first JPEG found; `null` when none is present.
25
+ */
26
+ declare function extractMetafileRaster(data: ArrayBuffer | Uint8Array): ExtractedRaster | null;
27
+ /** True for EMF/WMF MIME types — the formats browsers cannot render natively. */
28
+ declare function isMetafileMimeType(mimeType: string | undefined): boolean;
29
+ //#endregion
30
+ export { ExtractedRaster, extractMetafileRaster, isMetafileMimeType };
@@ -0,0 +1,80 @@
1
+ //#region src/docx/metafileRaster.ts
2
+ function indexOfBytes(haystack, needle, from = 0) {
3
+ outer: for (let i = from; i + needle.length <= haystack.length; i++) {
4
+ for (let j = 0; j < needle.length; j++) if (haystack[i + j] !== needle[j]) continue outer;
5
+ return i;
6
+ }
7
+ return -1;
8
+ }
9
+ function extractPng(bytes) {
10
+ const start = indexOfBytes(bytes, [
11
+ 137,
12
+ 80,
13
+ 78,
14
+ 71,
15
+ 13,
16
+ 10,
17
+ 26,
18
+ 10
19
+ ]);
20
+ if (start < 0) return null;
21
+ let off = start + 8;
22
+ while (off + 12 <= bytes.length) {
23
+ const b0 = bytes[off];
24
+ const b1 = bytes[off + 1];
25
+ const b2 = bytes[off + 2];
26
+ const b3 = bytes[off + 3];
27
+ const len = b0 << 24 | b1 << 16 | b2 << 8 | b3;
28
+ const type = String.fromCharCode(bytes[off + 4], bytes[off + 5], bytes[off + 6], bytes[off + 7]);
29
+ const next = off + 12 + (len >>> 0);
30
+ if (type === "IEND") return bytes.slice(start, off + 12);
31
+ if (next <= off || next > bytes.length) break;
32
+ off = next;
33
+ }
34
+ return null;
35
+ }
36
+ function extractJpeg(bytes) {
37
+ const start = indexOfBytes(bytes, [
38
+ 255,
39
+ 216,
40
+ 255
41
+ ]);
42
+ if (start < 0) return null;
43
+ let end = -1;
44
+ for (let i = bytes.length - 2; i > start + 1; i--) if (bytes[i] === 255 && bytes[i + 1] === 217) {
45
+ end = i + 2;
46
+ break;
47
+ }
48
+ return end > start ? bytes.slice(start, end) : null;
49
+ }
50
+ /**
51
+ * Scan a metafile (EMF or WMF) for an embedded browser-renderable raster.
52
+ * Returns the first PNG, else first JPEG found; `null` when none is present.
53
+ */
54
+ function extractMetafileRaster(data) {
55
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
56
+ if (bytes.length < 32) return null;
57
+ const png = extractPng(bytes);
58
+ if (png) return {
59
+ bytes: png,
60
+ mimeType: "image/png"
61
+ };
62
+ const jpeg = extractJpeg(bytes);
63
+ if (jpeg) return {
64
+ bytes: jpeg,
65
+ mimeType: "image/jpeg"
66
+ };
67
+ return null;
68
+ }
69
+ const METAFILE_MIME = /* @__PURE__ */ new Set([
70
+ "image/x-emf",
71
+ "image/emf",
72
+ "image/x-wmf",
73
+ "image/wmf"
74
+ ]);
75
+ /** True for EMF/WMF MIME types — the formats browsers cannot render natively. */
76
+ function isMetafileMimeType(mimeType) {
77
+ return !!mimeType && METAFILE_MIME.has(mimeType);
78
+ }
79
+ //#endregion
80
+ export { extractMetafileRaster, isMetafileMimeType };
@@ -815,7 +815,11 @@ function parseParagraphContents(paraElement, styles, theme, _numbering, rels, me
815
815
  parsedContent: parseParagraphContents(child, styles, theme, null, rels, media, "default", inScopeXmlns)
816
816
  });
817
817
  break;
818
- case "smartTag": break;
818
+ case "smartTag": {
819
+ const inner = parseParagraphContents(child, styles, theme, null, rels, media, trackedContext, mergeXmlnsDeclarations(inScopeXmlns, child));
820
+ contents.push(...inner);
821
+ break;
822
+ }
819
823
  case "moveFromRangeStart": {
820
824
  const id = Number.parseInt(getAttribute(child, "w", "id") ?? "0", 10);
821
825
  const name = getAttribute(child, "w", "name") ?? "";
@@ -7,6 +7,15 @@ import { DocxUnzipLimits } from "./unzip.js";
7
7
  * Progress callback for tracking parsing stages
8
8
  */
9
9
  type ProgressCallback = (stage: string, percent: number) => void;
10
+ /**
11
+ * Host hook for converting media the browser cannot render natively
12
+ * (EMF/WMF/TIFF) into a displayable `data:` or `blob:` URL. Receives the
13
+ * parsed {@link MediaFile} (original bytes on `.data`); return the replacement
14
+ * URL, or `null`/`undefined` to keep the built-in handling. Built-in handling
15
+ * already extracts an embedded PNG/JPEG from EMF/WMF when one exists; this
16
+ * hook is for vector-only metafiles where the host rasterizes server-side.
17
+ */
18
+ type MediaResolver = (file: document_d_exports.MediaFile) => Promise<string | null | undefined>;
10
19
  /**
11
20
  * Parsing options
12
21
  */
@@ -18,7 +27,8 @@ type ParseOptions = {
18
27
  detectVariables?: boolean; /** Security limits for DOCX ZIP extraction */
19
28
  unzipLimits?: Partial<Omit<DocxUnzipLimits, "allowedMediaMimeTypes">> & {
20
29
  allowedMediaMimeTypes?: Iterable<string>;
21
- };
30
+ }; /** Optional async hook to override display URLs for non-browser media. */
31
+ mediaResolver?: MediaResolver;
22
32
  };
23
33
  /**
24
34
  * Parse a DOCX file into a complete Document model
@@ -64,4 +74,4 @@ declare function getDocxSummary(buffer: ArrayBuffer): Promise<{
64
74
  variableCount: number;
65
75
  }>;
66
76
  //#endregion
67
- export { DocxParseError, ParseOptions, ProgressCallback, fullParseDocx, getDocxSummary, getDocxVariables, parseDocx, quickParseDocx };
77
+ export { DocxParseError, MediaResolver, ParseOptions, ProgressCallback, fullParseDocx, getDocxSummary, getDocxVariables, parseDocx, quickParseDocx };
@@ -12,6 +12,7 @@ import { normalizeCommentReferences } from "./commentReferenceNormalization.js";
12
12
  import { extractAllTemplateVariables, parseDocumentBody } from "./documentParser.js";
13
13
  import { parseFooter, parseHeader } from "./headerFooterParser.js";
14
14
  import { normalizeHeaderFooterReferences } from "./headerFooterReferenceNormalization.js";
15
+ import { extractMetafileRaster, isMetafileMimeType } from "./metafileRaster.js";
15
16
  import { normalizeNumberingReferences } from "./numberingReferenceNormalization.js";
16
17
  import { parseSettings } from "./settingsParser.js";
17
18
  import { parseStylesPackage } from "./styleParser.js";
@@ -47,7 +48,7 @@ import { TaggedError } from "better-result";
47
48
  */
48
49
  async function parseDocx(input, options = {}) {
49
50
  const buffer = input instanceof ArrayBuffer ? input : await toArrayBuffer(input);
50
- const { onProgress = () => {}, preloadFonts = true, parseHeadersFooters = true, parseNotes = true, detectVariables = true, unzipLimits } = options;
51
+ const { onProgress = () => {}, preloadFonts = true, parseHeadersFooters = true, parseNotes = true, detectVariables = true, unzipLimits, mediaResolver } = options;
51
52
  const warnings = [];
52
53
  try {
53
54
  const timeStage = (_name, fn) => fn();
@@ -80,6 +81,7 @@ async function parseDocx(input, options = {}) {
80
81
  onProgress("Parsed numbering", 35);
81
82
  onProgress("Processing media files...", 35);
82
83
  const media = await timeStageAsync("media", () => buildMediaMap(raw, rels));
84
+ if (mediaResolver) await timeStageAsync("mediaResolver", () => applyMediaResolver(media, mediaResolver));
83
85
  onProgress("Processed media", 40);
84
86
  onProgress("Parsing document body...", 40);
85
87
  let documentBody = { content: [] };
@@ -196,6 +198,11 @@ var DocxParseError = class extends TaggedError("DocxParseError")() {};
196
198
  /**
197
199
  * Build media file map from raw content and relationships
198
200
  */
201
+ function copyBytesToArrayBuffer(bytes) {
202
+ const buffer = new ArrayBuffer(bytes.byteLength);
203
+ new Uint8Array(buffer).set(bytes);
204
+ return buffer;
205
+ }
199
206
  async function buildMediaMap(raw, _rels) {
200
207
  const media = /* @__PURE__ */ new Map();
201
208
  for (const [path, data] of raw.media.entries()) {
@@ -217,6 +224,20 @@ async function buildMediaMap(raw, _rels) {
217
224
  continue;
218
225
  }
219
226
  }
227
+ const raster = isMetafileMimeType(mimeType) ? extractMetafileRaster(data) : null;
228
+ if (raster) {
229
+ const mediaFile = {
230
+ path,
231
+ filename,
232
+ mimeType,
233
+ data,
234
+ dataUrl: mediaToDataUrl(copyBytesToArrayBuffer(raster.bytes), raster.mimeType)
235
+ };
236
+ media.set(path, mediaFile);
237
+ const normalizedPath = path.replace(/^word\//u, "");
238
+ if (normalizedPath !== path) media.set(normalizedPath, mediaFile);
239
+ continue;
240
+ }
220
241
  const mediaFile = {
221
242
  path,
222
243
  filename,
@@ -230,6 +251,15 @@ async function buildMediaMap(raw, _rels) {
230
251
  }
231
252
  return media;
232
253
  }
254
+ async function applyMediaResolver(media, resolver) {
255
+ const files = [...new Set(media.values())];
256
+ await Promise.all(files.map(async (file) => {
257
+ try {
258
+ const url = await resolver(file);
259
+ if (url) file.dataUrl = url;
260
+ } catch {}
261
+ }));
262
+ }
233
263
  function attachLazyDataUrl(mediaFile) {
234
264
  let cachedDataUrl;
235
265
  Object.defineProperty(mediaFile, "dataUrl", {
@@ -2,6 +2,7 @@ import { escapeXml } from "./xmlUtils.js";
2
2
  import { serializeBlockSdt } from "./blockSdtSerializer.js";
3
3
  import { serializeParagraph } from "./paragraphSerializer.js";
4
4
  import { serializeTable } from "./tableSerializer.js";
5
+ import { canReplayHeaderFooterVerbatim, getHeaderFooterVerbatimXml } from "../headerFooterVerbatim.js";
5
6
  //#region src/docx/serializer/headerFooterSerializer.ts
6
7
  const NAMESPACES = {
7
8
  wpc: "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas",
@@ -45,6 +46,8 @@ function serializeBlock(block) {
45
46
  * @returns Complete XML string for header*.xml or footer*.xml
46
47
  */
47
48
  function serializeHeaderFooter(hf) {
49
+ const verbatim = getHeaderFooterVerbatimXml(hf);
50
+ if (verbatim && canReplayHeaderFooterVerbatim(hf)) return verbatim;
48
51
  const rootTag = hf.type === "header" ? "w:hdr" : "w:ftr";
49
52
  const nsDecl = buildNamespaceDeclarations();
50
53
  const watermarkXml = serializeWatermarkParagraph(hf);
@@ -1,4 +1,4 @@
1
- import { findChild, findChildren, getAttribute, getLocalName, parseBooleanElement, parseNumericAttribute, parseXmlDocument } from "./xmlParser.js";
1
+ import { findChild, findChildren, getAttribute, getLocalName, parseBooleanElement, parseNumericAttribute, parseTableMeasurementValue, parseXmlDocument } from "./xmlParser.js";
2
2
  import { BorderStyleSchema, ConditionalStyleTypeSchema, EmphasisMarkSchema, FontThemeSchema, HighlightColorSchema, LineSpacingRuleSchema, ParagraphAlignmentSchema, ShadingPatternSchema, StyleTypeSchema, TabLeaderSchema, TabStopAlignmentSchema, TableCellTextDirectionSchema, TableRowHeightRuleSchema, TableWidthTypeSchema, TextEffectSchema, ThemeColorSlotSchema, UnderlineStyleSchema, narrowEnum } from "./parserEnums.js";
3
3
  import { resolveThemeFontRef } from "./themeParser.js";
4
4
  import { mergeTextFormatting } from "../utils/textFormattingMerge.js";
@@ -360,9 +360,9 @@ function parseParagraphProperties(pPr, theme) {
360
360
  */
361
361
  function parseTableMeasurement(element) {
362
362
  if (!element) return;
363
- const w = parseNumericAttribute(element, "w", "w");
364
363
  const rawType = getAttribute(element, "w", "type");
365
364
  const type = rawType === null ? "dxa" : narrowEnum(rawType, TableWidthTypeSchema);
365
+ const w = type ? parseTableMeasurementValue(element, type) : void 0;
366
366
  if (w !== void 0 && type) return {
367
367
  value: w,
368
368
  type
@@ -1,4 +1,4 @@
1
- import { findChild, findChildByLocalName, findChildren, getAttribute, getChildElements, mergeXmlnsDeclarations, parseBooleanElement, parseNumericAttribute } from "./xmlParser.js";
1
+ import { findChild, findChildByLocalName, findChildren, getAttribute, getChildElements, mergeXmlnsDeclarations, parseBooleanElement, parseNumericAttribute, parseTableMeasurementValue } from "./xmlParser.js";
2
2
  import { parseBookmarkEnd, parseBookmarkStart } from "./bookmarkParser.js";
3
3
  import { BorderStyleSchema, FloatingTableXSpecSchema, FloatingTableYSpecSchema, ShadingPatternSchema, TableCellTextDirectionSchema, ThemeColorSlotSchema, narrowEnum } from "./parserEnums.js";
4
4
  import { parseParagraph } from "./paragraphParser.js";
@@ -12,12 +12,11 @@ import { appendBookmarkMarkerToLastParagraphInBlocks, appendBookmarkMarkerToLast
12
12
  */
13
13
  function parseTableMeasurement(element) {
14
14
  if (!element) return;
15
- const value = parseNumericAttribute(element, "w", "w") ?? 0;
16
15
  const typeStr = getAttribute(element, "w", "type") ?? "dxa";
17
16
  let type = "dxa";
18
17
  if (typeStr === "auto" || typeStr === "dxa" || typeStr === "nil" || typeStr === "pct") type = typeStr;
19
18
  return {
20
- value,
19
+ value: parseTableMeasurementValue(element, type) ?? 0,
21
20
  type
22
21
  };
23
22
  }
@@ -37,7 +37,9 @@ const DEFAULT_ALLOWED_MEDIA_MIME_TYPES = /* @__PURE__ */ new Set([
37
37
  "image/gif",
38
38
  "image/bmp",
39
39
  "image/tiff",
40
- "image/webp"
40
+ "image/webp",
41
+ "image/x-emf",
42
+ "image/x-wmf"
41
43
  ]);
42
44
  const PRESERVABLE_MEDIA_MIME_TYPES = /* @__PURE__ */ new Set([
43
45
  ...DEFAULT_ALLOWED_MEDIA_MIME_TYPES,
@@ -279,6 +281,10 @@ function isMediaContentAllowed(data, mimeType) {
279
281
  case "image/bmp": return bytes[0] === 66 && bytes[1] === 77;
280
282
  case "image/webp": return bytes[0] === 82 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80;
281
283
  case "image/tiff": return bytes[0] === 73 && bytes[1] === 73 || bytes[0] === 77 && bytes[1] === 77;
284
+ case "image/x-emf":
285
+ case "image/emf": return bytes.length >= 44 && bytes[0] === 1 && bytes[40] === 32 && bytes[41] === 69 && bytes[42] === 77 && bytes[43] === 70;
286
+ case "image/x-wmf":
287
+ case "image/wmf": return bytes.length >= 4 && (bytes[0] === 215 && bytes[1] === 205 && bytes[2] === 198 && bytes[3] === 154 || (bytes[0] === 1 || bytes[0] === 2) && bytes[1] === 0 && bytes[2] === 9 && bytes[3] === 0);
282
288
  default: return false;
283
289
  }
284
290
  }
@@ -237,6 +237,12 @@ declare function parseColorElement(element: XmlElement | null | undefined): {
237
237
  * @returns Parsed number or undefined
238
238
  */
239
239
  declare function parseNumericAttribute(element: XmlElement | null | undefined, namespace: string | null, name: string, scale?: number): number | undefined;
240
+ /**
241
+ * Parse `w:w` on a table width/height element. For `w:type="pct"`, producers
242
+ * sometimes emit human-readable percentages (`100%`) instead of 50ths-of-percent
243
+ * (`5000`); normalize those to the ECMA-376 unit the layout engine expects.
244
+ */
245
+ declare function parseTableMeasurementValue(element: XmlElement | null | undefined, widthType: string): number | undefined;
240
246
  /**
241
247
  * Parse a boolean value from an attribute or element presence
242
248
  *
@@ -294,4 +300,4 @@ declare function mergeXmlnsDeclarations(inherited: Record<string, string>, eleme
294
300
  */
295
301
  declare function cloneWithXmlnsDeclarations(element: XmlElement, xmlnsDecls: Record<string, string>): XmlElement;
296
302
  //#endregion
297
- export { NAMESPACES, XmlElement, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumericAttribute, parseXml, parseXmlDocument };
303
+ export { NAMESPACES, XmlElement, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumericAttribute, parseTableMeasurementValue, parseXml, parseXmlDocument };
@@ -409,6 +409,22 @@ function parseNumericAttribute(element, namespace, name, scale = 1) {
409
409
  return num * scale;
410
410
  }
411
411
  /**
412
+ * Parse `w:w` on a table width/height element. For `w:type="pct"`, producers
413
+ * sometimes emit human-readable percentages (`100%`) instead of 50ths-of-percent
414
+ * (`5000`); normalize those to the ECMA-376 unit the layout engine expects.
415
+ */
416
+ function parseTableMeasurementValue(element, widthType) {
417
+ const raw = getAttribute(element, "w", "w");
418
+ if (raw === null) return;
419
+ const trimmed = raw.trim();
420
+ if (widthType === "pct" && trimmed.endsWith("%")) {
421
+ const pct = Number.parseFloat(trimmed.slice(0, -1));
422
+ if (!Number.isNaN(pct)) return Math.round(pct * 50);
423
+ }
424
+ const num = Number.parseInt(trimmed, 10);
425
+ return Number.isNaN(num) ? void 0 : num;
426
+ }
427
+ /**
412
428
  * Parse a boolean value from an attribute or element presence
413
429
  *
414
430
  * OOXML boolean conventions:
@@ -524,4 +540,4 @@ function cloneWithXmlnsDeclarations(element, xmlnsDecls) {
524
540
  };
525
541
  }
526
542
  //#endregion
527
- export { NAMESPACES, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumericAttribute, parseXml, parseXmlDocument };
543
+ export { NAMESPACES, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumericAttribute, parseTableMeasurementValue, parseXml, parseXmlDocument };
@@ -1,5 +1,5 @@
1
1
  import { document_d_exports } from "../../types/document.js";
2
- import { FlowBlock, HeaderFooterContent, ImageRun, Measure, PageMargins } from "../../layout-engine/types.js";
2
+ import { FlowBlock, HeaderFooterContent, ImageRun, ImageRunPosition, Measure, PageMargins } from "../../layout-engine/types.js";
3
3
  import { MeasureBlocksFn } from "./footnoteLayout.js";
4
4
  import { Node } from "prosemirror-model";
5
5
 
@@ -13,6 +13,7 @@ type HeaderFooterMetrics = {
13
13
  margins: PageMargins;
14
14
  };
15
15
  declare function normalizeHeaderFooterMeasureBlocks(blocks: FlowBlock[]): FlowBlock[];
16
+ declare function resolveHeaderFooterPositionedVisualTop(position: ImageRunPosition | undefined, elementHeight: number, sourceY: number, flowHeight: number, metrics: HeaderFooterMetrics): number;
16
17
  declare function resolveHeaderFooterVisualTop(run: ImageRun, paragraphY: number, flowHeight: number, metrics: HeaderFooterMetrics): number;
17
18
  declare function calculateHeaderFooterVisualBounds(blocks: FlowBlock[], measures: Measure[], flowHeight: number, metrics: HeaderFooterMetrics): {
18
19
  visualTop: number;
@@ -71,4 +72,4 @@ declare function convertHeaderFooterToContent(headerFooter: document_d_exports.H
71
72
  */
72
73
  declare function convertHeaderFooterPmDocToContent(pmDoc: Node | null | undefined, contentWidth: number, metrics: HeaderFooterMetrics, options: Omit<ConvertHeaderFooterOptions, "styles">): HeaderFooterContent | undefined;
73
74
  //#endregion
74
- export { ConvertHeaderFooterOptions, HeaderFooterMetrics, calculateHeaderFooterMarginPushBounds, calculateHeaderFooterVisualBounds, convertHeaderFooterPmDocToContent, convertHeaderFooterToContent, normalizeHeaderFooterMeasureBlocks, resolveHeaderFooterVisualTop };
75
+ export { ConvertHeaderFooterOptions, HeaderFooterMetrics, calculateHeaderFooterMarginPushBounds, calculateHeaderFooterVisualBounds, convertHeaderFooterPmDocToContent, convertHeaderFooterToContent, normalizeHeaderFooterMeasureBlocks, resolveHeaderFooterPositionedVisualTop, resolveHeaderFooterVisualTop };
@@ -98,28 +98,43 @@ function normalizeTableBlock(block) {
98
98
  function getPositionAlignment(axis) {
99
99
  return axis?.align ?? axis?.alignment;
100
100
  }
101
- function resolveHeaderFooterVisualTop(run, paragraphY, flowHeight, metrics) {
101
+ function resolveHeaderFooterPositionedVisualTop(position, elementHeight, sourceY, flowHeight, metrics) {
102
102
  const flowTop = metrics.section === "header" ? metrics.margins.header ?? 48 : metrics.pageSize.h - (metrics.margins.footer ?? 48) - flowHeight;
103
- const vertical = run.position?.vertical;
104
- if (!vertical) return paragraphY;
103
+ const vertical = position?.vertical;
104
+ if (!vertical) return sourceY;
105
105
  const align = getPositionAlignment(vertical);
106
106
  const offsetPx = vertical.posOffset !== void 0 ? emuToPixels(vertical.posOffset) : void 0;
107
107
  if (vertical.relativeTo === "page") {
108
108
  if (offsetPx !== void 0) return offsetPx - flowTop;
109
109
  if (align === "top") return -flowTop;
110
- if (align === "bottom") return metrics.pageSize.h - run.height - flowTop;
111
- if (align === "center") return (metrics.pageSize.h - run.height) / 2 - flowTop;
110
+ if (align === "bottom") return metrics.pageSize.h - elementHeight - flowTop;
111
+ if (align === "center") return (metrics.pageSize.h - elementHeight) / 2 - flowTop;
112
112
  }
113
113
  if (vertical.relativeTo === "margin") {
114
114
  const marginTop = metrics.margins.top;
115
115
  const marginHeight = metrics.pageSize.h - metrics.margins.top - metrics.margins.bottom;
116
116
  if (offsetPx !== void 0) return marginTop + offsetPx - flowTop;
117
117
  if (align === "top") return marginTop - flowTop;
118
- if (align === "bottom") return marginTop + marginHeight - run.height - flowTop;
119
- if (align === "center") return marginTop + (marginHeight - run.height) / 2 - flowTop;
118
+ if (align === "bottom") return marginTop + marginHeight - elementHeight - flowTop;
119
+ if (align === "center") return marginTop + (marginHeight - elementHeight) / 2 - flowTop;
120
120
  }
121
- if (offsetPx !== void 0) return paragraphY + offsetPx;
122
- return paragraphY;
121
+ if (offsetPx !== void 0) return sourceY + offsetPx;
122
+ return sourceY;
123
+ }
124
+ function resolveHeaderFooterVisualTop(run, paragraphY, flowHeight, metrics) {
125
+ return resolveHeaderFooterPositionedVisualTop(run.position, run.height, paragraphY, flowHeight, metrics);
126
+ }
127
+ function pageBandInHeaderFooterCoords(metrics, flowHeight) {
128
+ const flowTop = metrics.section === "header" ? metrics.margins.header ?? 48 : metrics.pageSize.h - (metrics.margins.footer ?? 48) - flowHeight;
129
+ return {
130
+ top: -flowTop,
131
+ bottom: metrics.pageSize.h - flowTop
132
+ };
133
+ }
134
+ /** True when a float's box overlaps the page band in header/footer coordinates. */
135
+ function floatIntersectsPageBand(blockTop, blockBottom, metrics, flowHeight) {
136
+ const band = pageBandInHeaderFooterCoords(metrics, flowHeight);
137
+ return blockBottom > band.top && blockTop < band.bottom;
123
138
  }
124
139
  function resolveHeaderFooterFloatingTableVisualTop(floating, measure, sourceY, flowHeight, metrics) {
125
140
  const flowTop = metrics.section === "header" ? metrics.margins.header ?? 48 : metrics.pageSize.h - (metrics.margins.footer ?? 48) - flowHeight;
@@ -156,8 +171,10 @@ function calculateHeaderFooterVisualBounds(blocks, measures, flowHeight, metrics
156
171
  if (run.kind !== "image") continue;
157
172
  if (!run.position && !isFloatingImageRun(run)) continue;
158
173
  const runTop = resolveHeaderFooterVisualTop(run, paragraphStartY, flowHeight, metrics);
174
+ const runBottom = runTop + run.height;
175
+ if (!floatIntersectsPageBand(runTop, runBottom, metrics, flowHeight)) continue;
159
176
  visualTop = Math.min(visualTop, runTop);
160
- visualBottom = Math.max(visualBottom, runTop + run.height);
177
+ visualBottom = Math.max(visualBottom, runBottom);
161
178
  }
162
179
  cursorY = paragraphBottomY;
163
180
  } else {
@@ -178,11 +195,18 @@ function calculateHeaderFooterVisualBounds(blocks, measures, flowHeight, metrics
178
195
  cursorY = blockBottomY;
179
196
  } else if (block.kind === "table" && block.floating && measure.kind === "table") {
180
197
  const blockTop = resolveHeaderFooterFloatingTableVisualTop(block.floating, measure, cursorY, flowHeight, metrics);
181
- visualTop = Math.min(visualTop, blockTop);
182
- visualBottom = Math.max(visualBottom, blockTop + blockHeight);
198
+ const blockBottom = blockTop + blockHeight;
199
+ if (floatIntersectsPageBand(blockTop, blockBottom, metrics, flowHeight)) {
200
+ visualTop = Math.min(visualTop, blockTop);
201
+ visualBottom = Math.max(visualBottom, blockBottom);
202
+ }
183
203
  } else if (block.kind === "textBox" && isPositionedHeaderFooterTextBoxBlock(block) && measure.kind === "textBox") {
184
- visualTop = Math.min(visualTop, cursorY);
185
- visualBottom = Math.max(visualBottom, cursorY + measure.height);
204
+ const blockTop = resolveHeaderFooterPositionedVisualTop(block.position, measure.height, cursorY, flowHeight, metrics);
205
+ const blockBottom = blockTop + measure.height;
206
+ if (floatIntersectsPageBand(blockTop, blockBottom, metrics, flowHeight)) {
207
+ visualTop = Math.min(visualTop, blockTop);
208
+ visualBottom = Math.max(visualBottom, blockBottom);
209
+ }
186
210
  }
187
211
  }
188
212
  }
@@ -429,4 +453,4 @@ function serializeRunFmt(run) {
429
453
  return JSON.stringify(out);
430
454
  }
431
455
  //#endregion
432
- export { calculateHeaderFooterMarginPushBounds, calculateHeaderFooterVisualBounds, convertHeaderFooterPmDocToContent, convertHeaderFooterToContent, normalizeHeaderFooterMeasureBlocks, resolveHeaderFooterVisualTop };
456
+ export { calculateHeaderFooterMarginPushBounds, calculateHeaderFooterVisualBounds, convertHeaderFooterPmDocToContent, convertHeaderFooterToContent, normalizeHeaderFooterMeasureBlocks, resolveHeaderFooterPositionedVisualTop, resolveHeaderFooterVisualTop };
@@ -814,6 +814,7 @@ function convertParagraph(node, startPos, options) {
814
814
  id: nextBlockId(),
815
815
  runs,
816
816
  attrs,
817
+ ...pmAttrs.paraId ? { paraId: pmAttrs.paraId } : {},
817
818
  ...bookmarkNames && bookmarkNames.length > 0 ? { bookmarks: bookmarkNames } : {},
818
819
  pmStart: startPos,
819
820
  pmEnd: startPos + node.nodeSize
@@ -0,0 +1,10 @@
1
+ //#region src/layout-engine/measure/lineBreaks.d.ts
2
+ /**
3
+ * Indices in `text` where a line may end. Latin text breaks after
4
+ * whitespace or hyphen; CJK text can break after every ideograph so a
5
+ * partial line is filled instead of pushing the whole run to the next line.
6
+ */
7
+ declare function findWordBreaks(text: string): number[];
8
+ declare function isBreakChar(char: string | undefined): boolean;
9
+ //#endregion
10
+ export { findWordBreaks, isBreakChar };
@@ -0,0 +1,30 @@
1
+ import { isCjkCodePoint } from "../../utils/scriptSegments.js";
2
+ //#region src/layout-engine/measure/lineBreaks.ts
3
+ /**
4
+ * Indices in `text` where a line may end. Latin text breaks after
5
+ * whitespace or hyphen; CJK text can break after every ideograph so a
6
+ * partial line is filled instead of pushing the whole run to the next line.
7
+ */
8
+ function findWordBreaks(text) {
9
+ const breaks = [];
10
+ for (let index = 0; index < text.length;) {
11
+ const codePoint = text.codePointAt(index);
12
+ if (codePoint === void 0) break;
13
+ const charLength = codePoint > 65535 ? 2 : 1;
14
+ const char = text[index];
15
+ if (char === " " || char === "-" || char === " ") breaks.push(index + charLength);
16
+ else if (isCjkCodePoint(codePoint)) breaks.push(index + charLength);
17
+ index += charLength;
18
+ }
19
+ return breaks;
20
+ }
21
+ function isBreakChar(char) {
22
+ if (char === void 0) return false;
23
+ if (char === " " || char === "-" || char === " ") return true;
24
+ const codePoint = char.codePointAt(0);
25
+ if (codePoint === void 0) return false;
26
+ if (char >= "\udc00" && char <= "\udfff") return true;
27
+ return isCjkCodePoint(codePoint);
28
+ }
29
+ //#endregion
30
+ export { findWordBreaks, isBreakChar };
@@ -1,7 +1,7 @@
1
1
  import { setMeasureProvider } from "./measureProvider.js";
2
2
  import { buildFontString, getResolvedData, ptToPx } from "./measureHelpers.js";
3
- import { getCachedFontMetrics, getCachedTextWidth, getTextWidthCacheGeneration, setCachedFontMetrics, setCachedTextWidth } from "./cache.js";
4
3
  import { hasCjk, isCjkCodePoint, segmentByScript } from "../../utils/scriptSegments.js";
4
+ import { getCachedFontMetrics, getCachedTextWidth, getTextWidthCacheGeneration, setCachedFontMetrics, setCachedTextWidth } from "./cache.js";
5
5
  import { canPrefetchMeasurement, prefetchMeasurement } from "./measureWorker.js";
6
6
  import { WORKER_FONT_FINGERPRINT_TEXT, countCodePoints } from "./measureWorkerProtocol.js";
7
7
  import { panic } from "better-result";
@@ -8,6 +8,7 @@ import { isFloatingImageRun } from "../types.js";
8
8
  import { clampFloatingWrapMargins } from "./clampFloatingWrapMargins.js";
9
9
  import { getFloatingAvailableWidth, getFloatingMargins } from "./floatingZones.js";
10
10
  import { getListMarkerInlineWidth } from "./listMarkerWidth.js";
11
+ import { findWordBreaks, isBreakChar } from "./lineBreaks.js";
11
12
  //#region src/layout-engine/measure/measureParagraph.ts
12
13
  /**
13
14
  * Paragraph measurement module
@@ -207,18 +208,34 @@ function isEmptyTextRun(run) {
207
208
  * desync measurer and painter on tab advance for paragraphs with a tab
208
209
  * preceding a floating image.
209
210
  */
211
+ function isBlockLayoutImageRun(run) {
212
+ return run.wrapType === "topAndBottom" || run.displayMode === "block";
213
+ }
210
214
  function measureInlineWidthAfterTab(runs, tabIndex, fieldValues) {
211
215
  let width = 0;
212
216
  for (let i = tabIndex + 1; i < runs.length; i++) {
213
217
  const next = runs[i];
214
218
  if (!next || isTabRun(next) || isLineBreakRun(next)) break;
219
+ if (isImageRun(next)) {
220
+ if (isBlockLayoutImageRun(next)) break;
221
+ if (!isFloatingImageRun(next)) width += inlineImageBoundingBox(next).width || 0;
222
+ continue;
223
+ }
215
224
  if (isTextRun(next)) width += measureTextWidth(next.text || "", runToFontStyle(next));
216
225
  else if (isFieldRun(next)) width += measureTextWidth(fieldMeasureText(next, fieldValues), runToFontStyle(next));
217
- else if (isImageRun(next) && !isFloatingImageRun(next)) width += next.width || 0;
218
226
  else if (isMathRun(next)) width += measureTextWidth(next.plainText || "[equation]", runToFontStyle(next));
219
227
  }
220
228
  return width;
221
229
  }
230
+ function hasFollowingTabOnLine(runs, tabIndex) {
231
+ for (let i = tabIndex + 1; i < runs.length; i++) {
232
+ const next = runs[i];
233
+ if (!next || isLineBreakRun(next)) break;
234
+ if (isImageRun(next) && isBlockLayoutImageRun(next)) break;
235
+ if (isTabRun(next)) return true;
236
+ }
237
+ return false;
238
+ }
222
239
  /**
223
240
  * Width of the inline content preceding the first `.` in the runs that follow
224
241
  * a tab, used to anchor `decimal` tab stops. Mirrors `getTextAfterTab` +
@@ -249,21 +266,6 @@ function measureDecimalPrefixWidthAfterTab(runs, tabIndex, fieldValues) {
249
266
  if (decimalIndex === -1 || !firstRun) return 0;
250
267
  return measureTextWidth(text.slice(0, decimalIndex), runToFontStyle(firstRun));
251
268
  }
252
- /**
253
- * Find word break points in text
254
- * Returns array of indices where words end (after space/punctuation)
255
- */
256
- function findWordBreaks(text) {
257
- const breaks = [];
258
- for (let i = 0; i < text.length; i++) {
259
- const char = text[i];
260
- if (char === " " || char === "-" || char === " ") breaks.push(i + 1);
261
- }
262
- return breaks;
263
- }
264
- function isBreakChar(char) {
265
- return char === " " || char === "-" || char === " ";
266
- }
267
269
  function isSpaceOrTab(char) {
268
270
  return char === " " || char === " ";
269
271
  }
@@ -551,13 +553,17 @@ function measureParagraph(block, maxWidth, options) {
551
553
  const followingWidth = measureInlineWidthAfterTab(runs, runIndex, options?.fieldValues);
552
554
  const decimalPrefixWidth = measureDecimalPrefixWidthAfterTab(runs, runIndex, options?.fieldValues);
553
555
  const lineX = currentLine.width + currentLine.leftOffset;
554
- const tabWidth = calculateTabWidth(indentLeft + (lines.length === 0 ? firstLineOffset + markerInlineWidth : 0) + lineX, {
556
+ const isFirstLine = lines.length === 0;
557
+ const contentX = indentLeft + (isFirstLine ? firstLineOffset + markerInlineWidth : 0) + lineX;
558
+ let tabWidth = calculateTabWidth(contentX, {
555
559
  ...attrs?.tabs !== void 0 ? { explicitStops: attrs.tabs } : {},
556
560
  leftIndent: pixelsToTwips(indentLeft)
557
561
  }, {
558
562
  followingWidth,
559
563
  decimalPrefixWidth
560
564
  }).width;
565
+ const lineRightEdgeX = indentLeft + (isFirstLine ? firstLineOffset + markerInlineWidth : 0) + currentLine.availableWidth + currentLine.leftOffset;
566
+ if (!hasFollowingTabOnLine(runs, runIndex) && (tabWidth > 0 || followingWidth > 0) && contentX + tabWidth + followingWidth > lineRightEdgeX + WIDTH_TOLERANCE) tabWidth = Math.max(1, lineRightEdgeX - contentX - followingWidth);
561
567
  if (currentLine.width + tabWidth > currentLine.availableWidth + WIDTH_TOLERANCE) {
562
568
  startNewLine(runIndex, 0);
563
569
  updateMaxFont(style);
@@ -365,7 +365,8 @@ type ParagraphAttrs = {
365
365
  */
366
366
  type ParagraphBlock = {
367
367
  kind: "paragraph";
368
- id: BlockId;
368
+ id: BlockId; /** Stable Word `w14:paraId` / PM `paraId`, when available. */
369
+ paraId?: string;
369
370
  runs: Run[];
370
371
  attrs?: ParagraphAttrs;
371
372
  /** Names of bookmarks anchored to this paragraph; used to map a bookmark to
@@ -5,13 +5,13 @@ import { calculateTabWidth } from "../prosemirror/utils/tabCalculator.js";
5
5
  import { inlineImageBoundingBox, parseRotationDegrees, rotatedBoundingBox } from "../utils/rotationBoundingBox.js";
6
6
  import { isFloatingImageRun } from "../layout-engine/types.js";
7
7
  import { getListMarkerInlineWidth } from "../layout-engine/measure/listMarkerWidth.js";
8
+ import { hasCjk, segmentByScript } from "../utils/scriptSegments.js";
8
9
  import { applySdtDataAttrs } from "./sdtBoundary.js";
9
10
  import { applyImageVisualAttrs, hasImageVisualAttrs, wrapImageWithCrop } from "./renderImage.js";
10
11
  import { ommlToMathml } from "../docx/mathToMathml.js";
11
12
  import { evaluateFieldInstruction } from "../fields/evaluateField.js";
12
13
  import { AUTHOR_COLORS, getAuthorColorIdx } from "../utils/authorColors.js";
13
14
  import { detectBaseDirection } from "../utils/baseDirection.js";
14
- import { hasCjk, segmentByScript } from "../utils/scriptSegments.js";
15
15
  import { getAutomaticTextColorForBackground } from "./documentColors.js";
16
16
  import { resolveImageLineAlign } from "./renderUtils.js";
17
17
  //#region src/layout-painter/renderParagraph.ts
@@ -1075,6 +1075,7 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1075
1075
  fragmentEl.className = PARAGRAPH_CLASS_NAMES.fragment;
1076
1076
  fragmentEl.style.position = "relative";
1077
1077
  fragmentEl.dataset["blockId"] = String(fragment.blockId);
1078
+ if (block.paraId) fragmentEl.dataset["paraId"] = block.paraId;
1078
1079
  fragmentEl.dataset["fromLine"] = String(fragment.fromLine);
1079
1080
  fragmentEl.dataset["toLine"] = String(fragment.toLine);
1080
1081
  applyPmPositions(fragmentEl, fragment.pmStart, fragment.pmEnd);
@@ -0,0 +1,17 @@
1
+ import { ParagraphHighlightOptions, ScrollToParaIdOptions } from "./paragraphFlashTypes.js";
2
+
3
+ //#region src/paged-layout/paragraphFlash.d.ts
4
+ /** Default color used by paragraph flashes. */
5
+ declare const DEFAULT_PARAGRAPH_FLASH_COLOR = "rgba(255, 235, 59, 0.55)";
6
+ /** Default duration for paragraph flashes. */
7
+ declare const DEFAULT_PARAGRAPH_FLASH_DURATION_MS = 1200;
8
+ /** CSS class applied to paragraph fragments during a transient flash. */
9
+ declare const PARAGRAPH_FLASH_CLASS_NAME = "folio-paragraph-flash";
10
+ /** Find all painted paragraph fragments with a stable `data-para-id`. */
11
+ declare const findParagraphFragmentsByParaId: (root: ParentNode, paraId: string) => HTMLElement[];
12
+ /** Apply a transient flash to a collection of paragraph elements. */
13
+ declare const flashParagraphElements: (elements: Iterable<HTMLElement>, options?: ParagraphHighlightOptions) => number;
14
+ /** Find paragraph fragments by `paraId` and flash them. */
15
+ declare const flashParagraphFragmentsByParaId: (root: ParentNode, paraId: string, options?: ParagraphHighlightOptions) => boolean;
16
+ //#endregion
17
+ export { DEFAULT_PARAGRAPH_FLASH_COLOR, DEFAULT_PARAGRAPH_FLASH_DURATION_MS, PARAGRAPH_FLASH_CLASS_NAME, type ParagraphHighlightOptions, type ScrollToParaIdOptions, findParagraphFragmentsByParaId, flashParagraphElements, flashParagraphFragmentsByParaId };
@@ -0,0 +1,56 @@
1
+ //#region src/paged-layout/paragraphFlash.ts
2
+ /** Default color used by paragraph flashes. */
3
+ const DEFAULT_PARAGRAPH_FLASH_COLOR = "rgba(255, 235, 59, 0.55)";
4
+ /** Default duration for paragraph flashes. */
5
+ const DEFAULT_PARAGRAPH_FLASH_DURATION_MS = 1200;
6
+ /** CSS class applied to paragraph fragments during a transient flash. */
7
+ const PARAGRAPH_FLASH_CLASS_NAME = "folio-paragraph-flash";
8
+ const timers = /* @__PURE__ */ new WeakMap();
9
+ const escapeAttributeValue = (value) => {
10
+ const cssGlobal = globalThis;
11
+ if (typeof cssGlobal.CSS?.escape === "function") return cssGlobal.CSS.escape(value);
12
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
13
+ };
14
+ const normalizedColor = (options) => {
15
+ return options?.color?.trim() || "rgba(255, 235, 59, 0.55)";
16
+ };
17
+ const normalizedDurationMs = (options) => {
18
+ const duration = options?.durationMs;
19
+ if (duration == null) return DEFAULT_PARAGRAPH_FLASH_DURATION_MS;
20
+ if (!Number.isFinite(duration) || duration < 0) return DEFAULT_PARAGRAPH_FLASH_DURATION_MS;
21
+ return duration;
22
+ };
23
+ /** Find all painted paragraph fragments with a stable `data-para-id`. */
24
+ const findParagraphFragmentsByParaId = (root, paraId) => {
25
+ if (!paraId || !paraId.trim()) return [];
26
+ const escaped = escapeAttributeValue(paraId);
27
+ return Array.from(root.querySelectorAll(`.layout-paragraph[data-para-id="${escaped}"]`));
28
+ };
29
+ /** Apply a transient flash to a collection of paragraph elements. */
30
+ const flashParagraphElements = (elements, options) => {
31
+ let count = 0;
32
+ const color = normalizedColor(options);
33
+ const durationMs = normalizedDurationMs(options);
34
+ for (const el of elements) {
35
+ count++;
36
+ const existingTimer = timers.get(el);
37
+ if (existingTimer !== void 0) clearTimeout(existingTimer);
38
+ el.classList.remove(PARAGRAPH_FLASH_CLASS_NAME);
39
+ el.offsetWidth;
40
+ el.style.setProperty("--folio-paragraph-flash-color", color);
41
+ el.style.setProperty("--folio-paragraph-flash-duration", `${durationMs}ms`);
42
+ el.classList.add(PARAGRAPH_FLASH_CLASS_NAME);
43
+ const timer = setTimeout(() => {
44
+ el.classList.remove(PARAGRAPH_FLASH_CLASS_NAME);
45
+ el.style.removeProperty("--folio-paragraph-flash-color");
46
+ el.style.removeProperty("--folio-paragraph-flash-duration");
47
+ timers.delete(el);
48
+ }, durationMs);
49
+ timers.set(el, timer);
50
+ }
51
+ return count;
52
+ };
53
+ /** Find paragraph fragments by `paraId` and flash them. */
54
+ const flashParagraphFragmentsByParaId = (root, paraId, options) => flashParagraphElements(findParagraphFragmentsByParaId(root, paraId), options) > 0;
55
+ //#endregion
56
+ export { DEFAULT_PARAGRAPH_FLASH_COLOR, DEFAULT_PARAGRAPH_FLASH_DURATION_MS, PARAGRAPH_FLASH_CLASS_NAME, findParagraphFragmentsByParaId, flashParagraphElements, flashParagraphFragmentsByParaId };
@@ -0,0 +1,18 @@
1
+ //#region src/paged-layout/paragraphFlashTypes.d.ts
2
+ /**
3
+ * Option shapes for `scrollToParaId(paraId, { highlight })`.
4
+ *
5
+ * DOM-free so non-browser consumers can type-import without pulling in
6
+ * paragraph-flash DOM helpers.
7
+ */
8
+ /** Customization for the transient paragraph flash applied by `scrollToParaId`. */
9
+ type ParagraphHighlightOptions = {
10
+ /** CSS color used for the transient paragraph flash. Defaults to yellow. */color?: string; /** How long the flash remains visible before it is removed. Defaults to 1200ms. */
11
+ durationMs?: number;
12
+ };
13
+ /** Optional reveal behavior for `scrollToParaId`. */
14
+ type ScrollToParaIdOptions = {
15
+ /** Flash rendered paragraph fragments after scrolling to the paragraph. */highlight?: ParagraphHighlightOptions;
16
+ };
17
+ //#endregion
18
+ export { ParagraphHighlightOptions, ScrollToParaIdOptions };
File without changes
@@ -1,3 +1,4 @@
1
+ import { clearHeaderFooterVerbatimXml } from "../docx/headerFooterVerbatim.js";
1
2
  //#region src/utils/headerFooter.ts
2
3
  const EMPTY_RESOLUTION = {
3
4
  headerContent: null,
@@ -206,6 +207,7 @@ const saveHeaderFooterContent = ({ document, position, isFirstPage, activeRId, b
206
207
  hdrFtrType: isFirstPage ? "first" : "default",
207
208
  content: blocks
208
209
  };
210
+ clearHeaderFooterVerbatimXml(updated);
209
211
  const newMap = new Map(map);
210
212
  newMap.set(activeRId, updated);
211
213
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",