@oh-my-pi/pi-utils 18.6.3 → 18.8.0

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/types/ar/index.d.ts +1 -0
  3. package/dist/types/ar/zip-package.d.ts +21 -0
  4. package/dist/types/docx/converter.d.ts +2 -0
  5. package/dist/types/dom/core.d.ts +15 -4
  6. package/dist/types/dom/parser.d.ts +5 -3
  7. package/dist/types/dom/selector.d.ts +7 -1
  8. package/dist/types/logger/rotating-file.d.ts +4 -2
  9. package/dist/types/marked/core.d.ts +7 -6
  10. package/dist/types/marked-list.d.ts +38 -0
  11. package/dist/types/math-delimiters.d.ts +10 -0
  12. package/dist/types/readability/types.d.ts +2 -0
  13. package/dist/types/turndown/types.d.ts +7 -0
  14. package/dist/types/vterm/buffer.d.ts +16 -5
  15. package/package.json +2 -2
  16. package/src/ar/index.ts +1 -0
  17. package/src/ar/zip-package.ts +57 -0
  18. package/src/chalk.ts +66 -23
  19. package/src/docx/converter.ts +26 -12
  20. package/src/dom/core.ts +354 -111
  21. package/src/dom/parser.ts +42 -19
  22. package/src/dom/selector.ts +328 -95
  23. package/src/fetch-retry.ts +2 -0
  24. package/src/json-parse.ts +21 -6
  25. package/src/logger/rotating-file.ts +5 -3
  26. package/src/logger.ts +39 -23
  27. package/src/marked/core.ts +42 -18
  28. package/src/marked-list.ts +73 -0
  29. package/src/math-delimiters.ts +16 -0
  30. package/src/prompt.ts +3 -1
  31. package/src/ptree.ts +28 -35
  32. package/src/readability/readability.ts +349 -39
  33. package/src/readability/types.ts +2 -0
  34. package/src/stream.ts +34 -13
  35. package/src/temp.ts +1 -10
  36. package/src/template.ts +7 -4
  37. package/src/turndown/gfm.ts +16 -5
  38. package/src/turndown/html.ts +28 -8
  39. package/src/turndown/service.ts +43 -15
  40. package/src/turndown/types.ts +7 -0
  41. package/src/vterm/buffer.ts +31 -11
  42. package/src/vterm/terminal.ts +185 -92
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.0] - 2026-10-07
6
+
7
+ ### Added
8
+
9
+ - Added `ZipPackage` to `@oh-my-pi/pi-utils/ar` for lazily reading ZIP-based document packages with a configurable total-inflation limit, plus `DocxImage.readBytes()` for accessing raw DOCX image data.
10
+
11
+ ### Changed
12
+
13
+ - Improved DOCX conversion to inflate only the package contents it needs, reducing unnecessary work and memory use.
14
+ - Improved performance across HTML-to-Markdown conversion, Readability extraction, Markdown lexing, terminal emulation, terminal styling, streaming tool-argument parsing, and log writing. Large-page processing and terminal workloads now use substantially less time and memory.
15
+
16
+ ### Fixed
17
+
18
+ - Fixed memory growth in long-lived child processes, streaming readers, prompt template compilation, and retried HTTP requests by releasing buffers, cache entries, and discarded response bodies promptly.
19
+ - Fixed prompt templates rejecting `{{else if …}}` chains as unclosed blocks; a chain now closes with its opening block's single closing tag, as in Handlebars.
20
+
5
21
  ## [18.6.3] - 2026-10-06
6
22
 
7
23
  ### Breaking Changes
@@ -9,3 +9,4 @@ export * from "./registry.js";
9
9
  export * from "./source.js";
10
10
  export * from "./types.js";
11
11
  export * from "./write.js";
12
+ export * from "./zip-package.js";
@@ -0,0 +1,21 @@
1
+ import type { ArchiveReader } from "./reader.js";
2
+ /**
3
+ * A ZIP-based document package (OOXML, EPUB) whose members are inflated only
4
+ * when read. Lookups use exact member paths, so a converter sees the same
5
+ * members a fully materialized `path → bytes` map would expose. Each member's
6
+ * size counts once toward a cap on total inflated bytes, like a fully
7
+ * materialized archive, so a crafted package cannot amplify past the in-memory
8
+ * limit one member at a time.
9
+ */
10
+ export declare class ZipPackage {
11
+ #private;
12
+ constructor(archive: ArchiveReader, maxTotalBytes?: number);
13
+ /** Index a ZIP buffer without inflating any member. */
14
+ static open(bytes: Uint8Array, maxTotalBytes?: number): Promise<ZipPackage>;
15
+ /** Exact paths of every file member. */
16
+ get members(): ReadonlySet<string>;
17
+ /** Inflate one member, or `undefined` when absent. Throws once distinct inflated bytes exceed the cap. */
18
+ readBytes(memberPath: string): Promise<Uint8Array | undefined>;
19
+ /** Inflate one member as UTF-8 text, or `undefined` when absent. */
20
+ readText(memberPath: string): Promise<string | undefined>;
21
+ }
@@ -22,6 +22,8 @@ export interface DocxImage {
22
22
  readonly altText: string;
23
23
  /** Read the image payload using mammoth's used encoding surface. */
24
24
  read(encoding: "base64"): Promise<string>;
25
+ /** Read the raw image bytes without a base64 round trip. */
26
+ readBytes(): Promise<Uint8Array>;
25
27
  }
26
28
  /** HTML attributes returned by a custom image converter. */
27
29
  export type ImageAttributes = Readonly<Record<string, string>>;
@@ -59,6 +59,7 @@ export declare class EventTarget {
59
59
  }
60
60
  /** Base class for the implemented DOM tree. */
61
61
  export declare class Node extends EventTarget {
62
+ #private;
62
63
  static readonly ELEMENT_NODE = NodeType.ELEMENT;
63
64
  static readonly ATTRIBUTE_NODE = NodeType.ATTRIBUTE;
64
65
  static readonly TEXT_NODE = NodeType.TEXT;
@@ -69,8 +70,12 @@ export declare class Node extends EventTarget {
69
70
  readonly nodeName: string;
70
71
  parentNode: Node | null;
71
72
  ownerDocument: Document | null;
72
- childNodes: Node[];
73
73
  constructor(nodeType: number, nodeName: string, ownerDocument?: Document | null);
74
+ /** Child nodes in tree order. */
75
+ get childNodes(): Node[];
76
+ set childNodes(value: Node[]);
77
+ /** Whether this node has any children (without allocating a child list). */
78
+ hasChildNodes(): boolean;
74
79
  /** First child node, if present. */
75
80
  get firstChild(): Node | null;
76
81
  /** Last child node, if present. */
@@ -196,14 +201,20 @@ export declare class CSSStyleDeclaration {
196
201
  }
197
202
  /** DOM element with attributes, selectors, and HTML serialization. */
198
203
  export declare class Element extends Node {
204
+ #private;
199
205
  readonly localName: string;
200
206
  readonly tagName: string;
201
207
  readonly namespaceURI: string | null;
202
208
  readonly qualifiedName: string;
203
- readonly attributes: NamedNodeMap;
204
- readonly classList: DOMTokenList;
205
- readonly style: CSSStyleDeclaration;
206
209
  constructor(tagName: string, ownerDocument?: Document | null, namespaceURI?: string | null);
210
+ /** Attribute collection. */
211
+ get attributes(): NamedNodeMap;
212
+ /** Class token list over the `class` attribute. */
213
+ get classList(): DOMTokenList;
214
+ /** Inline style declaration. */
215
+ get style(): CSSStyleDeclaration;
216
+ /** Whether the element has any attribute objects (without allocating the collection). */
217
+ hasAttributes(): boolean;
207
218
  /** Element children. */
208
219
  get children(): Element[];
209
220
  /** First element child. */
@@ -1,7 +1,9 @@
1
- import { Document, type DocumentFragment } from "./core.js";
1
+ import { Document, type DocumentFragment, Element } from "./core.js";
2
2
  /** Decode common named entities and all valid numeric character references. */
3
3
  export declare function decodeEntities(value: string): string;
4
- /** Parse markup into a document fragment. */
5
- export declare function parseFragment(html: string, document: Document, contextTag?: string): DocumentFragment;
4
+ /** Parse markup, appending the resulting nodes to `root` (a document, fragment, or element). */
5
+ export declare function parseInto(html: string, document: Document, root: Document | DocumentFragment | Element): void;
6
+ /** Parse markup into a document fragment. `_contextTag` is accepted for compatibility and ignored. */
7
+ export declare function parseFragment(html: string, document: Document, _contextTag?: string): DocumentFragment;
6
8
  /** Parse markup directly into a document. */
7
9
  export declare function parseDocument(html: string): Document;
@@ -1,5 +1,11 @@
1
1
  import { type Document, type DocumentFragment, Element } from "./core.js";
2
+ /** Whether a whitespace-separated class string contains a token, without splitting it. */
3
+ export declare function hasClassToken(className: string, token: string): boolean;
2
4
  /** Whether an element matches a CSS selector list. */
3
5
  export declare function matchesSelector(element: Element, selector: string): boolean;
6
+ /** Nearest inclusive ancestor of an element matching a CSS selector list. */
7
+ export declare function closestMatching(element: Element, selector: string): Element | null;
4
8
  /** Query descendants of a node in document order. */
5
- export declare function querySelectorAllFrom(root: Document | DocumentFragment | Element, selector: string, includeRoot: boolean): Element[];
9
+ export declare function querySelectorAllFrom(root: Document | DocumentFragment | Element, selector: string, _includeRoot: boolean): Element[];
10
+ /** First descendant of a node in document order matching a selector, stopping at the first match. */
11
+ export declare function querySelectorFrom(root: Document | DocumentFragment | Element, selector: string): Element | null;
@@ -47,9 +47,11 @@ export declare class RotatingFileSink {
47
47
  /**
48
48
  * Append one already-formatted log record. A batching sink buffers it unless
49
49
  * `flushNow` is set or the buffer reached its size threshold; either way the
50
- * whole buffer, this record included, is then written in order.
50
+ * whole buffer, this record included, is then written in order. `day` is the
51
+ * record's `localDay` key, so callers that already took the record's `Date`
52
+ * reuse it.
51
53
  */
52
- write(line: string, flushNow?: boolean): void;
54
+ write(line: string, flushNow?: boolean, day?: string): void;
53
55
  /** Write every buffered record to the active file now. */
54
56
  flush(): void;
55
57
  /** Write buffered records, then stop accepting records. */
@@ -185,14 +185,15 @@ export type TokensList = Token[] & {
185
185
  export interface TokenizerThis {
186
186
  lexer: Lexer;
187
187
  /**
188
- * For an inline tokenizer, the whole inline source, such as a paragraph's text. The `src` it receives is the part
189
- * of it before `end` that starts at `end - src.length`: inside a link label or the text of emphasis, `end` is where
190
- * that text ends, right before its closer, and a token there must not reach past it. Inline tokenizers get one
191
- * context object per inline source, the same for every call while the lexer works through it and the labels and
192
- * emphasis inside it, so a tokenizer can keep state for it in a `WeakMap`.
188
+ * The whole source the lexer works through. The `src` a tokenizer receives is the part of it before `end` that
189
+ * starts at `end - src.length`. For a block tokenizer, `source` is the text passed to one `blockTokens` call and
190
+ * `end` its length. For an inline tokenizer, it is the inline source, such as a paragraph's text: inside a link
191
+ * label or the text of emphasis, `end` is where that text ends, right before its closer, and a token there must
192
+ * not reach past it. Tokenizers get one context object per source, the same for every call while the lexer works
193
+ * through it (and, inline, the labels and emphasis inside it), so a tokenizer can keep state for it in a `WeakMap`.
193
194
  */
194
195
  source?: string;
195
- /** For an inline tokenizer, where the text the lexer works through ends in `source` (see `source`). */
196
+ /** Where the text the lexer works through ends in `source` (see `source`). */
196
197
  end?: number;
197
198
  }
198
199
  /** A tokenizer extension callback. */
@@ -0,0 +1,38 @@
1
+ /**
2
+ * List-continuation grammar of `@oh-my-pi/pi-utils/marked`, for streaming
3
+ * renderers that freeze a lexed prefix at blank-line boundaries (TUI and
4
+ * collab web).
5
+ *
6
+ * marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
7
+ * blank lines only when the remaining source matches
8
+ * `listItemRegex(marker)` = `^( {0,3}${marker})((?:[\t ][^\n]*)?(?:\n|$))`,
9
+ * where `marker` is the exact bullet char for unordered lists (`\${char}`) or
10
+ * 1-9 digits plus the exact delimiter for ordered lists (`\d{1,9}\${delim}`).
11
+ * The marker is derived from the list's FIRST item (`n = t[1].trim()`), which
12
+ * sits at the start of a top-level list token's raw.
13
+ */
14
+ /**
15
+ * Whether a same-marker item could still continue the list `listRaw` at
16
+ * `tailStart` (directly after the blank line ending the list), now or after
17
+ * any append to `text`.
18
+ *
19
+ * Streaming-freeze equivalence invariant: lex(prefix) ++ lex(tail) must equal
20
+ * lex(full text) for the current text and every append-only extension of it,
21
+ * because a frozen prefix is sticky. At a blank-line cut directly after a
22
+ * top-level `list` token, the only construct that can straddle the cut is a
23
+ * continuation item of that list: marked consumed the blank line into the last
24
+ * item's raw and re-ran `listItemRegex` at exactly `tailStart`, merging a
25
+ * same-marker item into one renumbered loose list. The cut is safe only when
26
+ * that regex can NEVER match at `tailStart`, no matter what is appended later.
27
+ *
28
+ * Append-only growth means "closed" may only be concluded from a present
29
+ * character that contradicts every possible continuation (tail "1x" can never
30
+ * grow into an ordered item, but tail "1" can become "1. c"). Running out of
31
+ * text mid-marker therefore answers "may continue".
32
+ *
33
+ * Returns true when the tail could still continue the list (or the list's
34
+ * marker is unrecognizable) — the conservative "don't freeze" answer. marked
35
+ * may break the list anyway when the matching line is also an hr (`- - -`);
36
+ * treating that as "may continue" merely skips a freeze, never corrupts one.
37
+ */
38
+ export declare function listMayContinueAt(text: string, tailStart: number, listRaw: string): boolean;
@@ -80,6 +80,16 @@ export declare class MathBlockScan {
80
80
  /** The own-line display block starting at `from`, or `undefined`. */
81
81
  at(from: number): MathBlock | undefined;
82
82
  }
83
+ /**
84
+ * The own-line display block at the start of `src`, for a marked block tokenizer: `src` is the part of
85
+ * `context.source` that starts at `context.end - src.length`. One {@link MathBlockScan} is kept per context, which
86
+ * marked keeps for one `blockTokens` source, so asking at every block start takes linear time even when an opener
87
+ * never closes. Without `context.source` it is {@link mathBlockAt}.
88
+ */
89
+ export declare function mathBlockInContext(context: {
90
+ source?: string;
91
+ end?: number;
92
+ }, src: string): MathBlock | undefined;
83
93
  /**
84
94
  * The display opener alone on the line at `from` (up to 3 leading spaces,
85
95
  * trailing spaces or tabs; the source's last line may be unterminated), or
@@ -13,6 +13,8 @@ export interface ReadabilityNode {
13
13
  appendChild(node: ReadabilityNode): ReadabilityNode;
14
14
  cloneNode(deep?: boolean): ReadabilityNode;
15
15
  remove(): void;
16
+ /** Detach all children; used when present to move or reset children in one step. */
17
+ replaceChildren?(): void;
16
18
  }
17
19
  /** Minimal standards-shaped DOM element accepted by the readability extractor. */
18
20
  export interface ReadabilityElement extends ReadabilityNode {
@@ -12,6 +12,8 @@ export interface TurndownNode {
12
12
  readonly previousSibling: TurndownNode | null;
13
13
  readonly textContent: string | null;
14
14
  readonly outerHTML?: string;
15
+ /** Position among the parent's element children, when the node implementation tracks it. */
16
+ readonly elementIndex?: number;
15
17
  getAttribute(name: string): string | null;
16
18
  hasAttribute(name: string): boolean;
17
19
  }
@@ -55,6 +57,11 @@ export type ReplacementFunction = (content: string, node: TurndownNode, options:
55
57
  export interface TurndownRule {
56
58
  filter: RuleFilter;
57
59
  replacement: ReplacementFunction;
60
+ /**
61
+ * Set when `replacement` ignores its `content` argument; the service then skips
62
+ * converting the node's children before calling it.
63
+ */
64
+ skipContent?: boolean;
58
65
  }
59
66
  /** A plugin that installs one or more conversion rules. */
60
67
  export type TurndownPlugin = (service: TurndownServiceLike) => void;
@@ -12,16 +12,27 @@ export interface CellAttributes {
12
12
  bgMode: 0 | 1 | 2;
13
13
  bg: number;
14
14
  }
15
- /** A mutable terminal grid cell. */
15
+ /**
16
+ * A terminal grid cell. Cells written by the terminal are frozen and may be
17
+ * shared between grid positions (blank cells, repeated ASCII glyphs), so the
18
+ * type is readonly: replace a cell instead of mutating it.
19
+ */
16
20
  export interface CellData {
17
- chars: string;
18
- width: number;
19
- attrs: CellAttributes;
21
+ readonly chars: string;
22
+ readonly width: number;
23
+ readonly attrs: Readonly<CellAttributes>;
20
24
  }
21
25
  /** Creates the default rendition. */
22
26
  export declare function defaultAttributes(): CellAttributes;
27
+ /** Frozen default rendition shared by every unstyled cell. */
28
+ export declare const DEFAULT_CELL_ATTRIBUTES: Readonly<CellAttributes>;
23
29
  /** Creates an empty cell with the supplied rendition. */
24
30
  export declare function blankCell(attrs?: CellAttributes): CellData;
31
+ /**
32
+ * Returns the frozen empty cell shared by every blank position with `attrs`.
33
+ * Frozen renditions are interned; a mutable one is snapshotted first.
34
+ */
35
+ export declare function sharedBlankCell(attrs?: Readonly<CellAttributes>): CellData;
25
36
  /** Proposed xterm-compatible cell readback object. */
26
37
  export declare class BufferCell {
27
38
  #private;
@@ -62,7 +73,7 @@ export declare class BufferCell {
62
73
  export declare class BufferLine {
63
74
  cells: CellData[];
64
75
  isWrapped: boolean;
65
- constructor(columns: number, attrs?: CellAttributes);
76
+ constructor(columns: number, attrs?: Readonly<CellAttributes>);
66
77
  /** Number of grid columns in the line. */
67
78
  get length(): number;
68
79
  /** Reads a cell, optionally reusing the caller's object. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.6.3",
3
+ "version": "18.8.0",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -57,7 +57,7 @@
57
57
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
58
58
  },
59
59
  "dependencies": {
60
- "@oh-my-pi/pi-natives": "18.6.3"
60
+ "@oh-my-pi/pi-natives": "18.8.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
package/src/ar/index.ts CHANGED
@@ -14,3 +14,4 @@ export * from "./registry";
14
14
  export * from "./source";
15
15
  export * from "./types";
16
16
  export * from "./write";
17
+ export * from "./zip-package";
@@ -0,0 +1,57 @@
1
+ import { UTF8_DECODER } from "./bytes";
2
+ import { type ArchiveLimits, assertInMemorySize, DEFAULT_ARCHIVE_LIMITS } from "./limits";
3
+ import { openArchive } from "./open";
4
+ import type { ArchiveReader } from "./reader";
5
+
6
+ /**
7
+ * A ZIP-based document package (OOXML, EPUB) whose members are inflated only
8
+ * when read. Lookups use exact member paths, so a converter sees the same
9
+ * members a fully materialized `path → bytes` map would expose. Each member's
10
+ * size counts once toward a cap on total inflated bytes, like a fully
11
+ * materialized archive, so a crafted package cannot amplify past the in-memory
12
+ * limit one member at a time.
13
+ */
14
+ export class ZipPackage {
15
+ #archive: ArchiveReader;
16
+ #members = new Set<string>();
17
+ #limits: ArchiveLimits;
18
+ /** Members already counted toward {@link #inflated}; re-reading one is free against the cap. */
19
+ #counted = new Set<string>();
20
+ #inflated = 0;
21
+
22
+ constructor(archive: ArchiveReader, maxTotalBytes = DEFAULT_ARCHIVE_LIMITS.maxInMemorySize) {
23
+ this.#archive = archive;
24
+ this.#limits = { ...DEFAULT_ARCHIVE_LIMITS, maxInMemorySize: maxTotalBytes };
25
+ for (const entry of archive.indexEntries()) {
26
+ if (!entry.isDirectory) this.#members.add(entry.path);
27
+ }
28
+ }
29
+
30
+ /** Index a ZIP buffer without inflating any member. */
31
+ static async open(bytes: Uint8Array, maxTotalBytes?: number): Promise<ZipPackage> {
32
+ return new ZipPackage(await openArchive({ bytes, format: "zip" }), maxTotalBytes);
33
+ }
34
+
35
+ /** Exact paths of every file member. */
36
+ get members(): ReadonlySet<string> {
37
+ return this.#members;
38
+ }
39
+
40
+ /** Inflate one member, or `undefined` when absent. Throws once distinct inflated bytes exceed the cap. */
41
+ async readBytes(memberPath: string): Promise<Uint8Array | undefined> {
42
+ if (!this.#members.has(memberPath)) return undefined;
43
+ const { bytes } = await this.#archive.readFile(memberPath);
44
+ if (!this.#counted.has(memberPath)) {
45
+ this.#counted.add(memberPath);
46
+ this.#inflated += bytes.length;
47
+ assertInMemorySize(this.#inflated, this.#limits);
48
+ }
49
+ return bytes;
50
+ }
51
+
52
+ /** Inflate one member as UTF-8 text, or `undefined` when absent. */
53
+ async readText(memberPath: string): Promise<string | undefined> {
54
+ const bytes = await this.readBytes(memberPath);
55
+ return bytes ? UTF8_DECODER.decode(bytes) : undefined;
56
+ }
57
+ }
package/src/chalk.ts CHANGED
@@ -257,14 +257,74 @@ function applyStyles(input: string, styles: readonly Style[]): string {
257
257
  for (let index = styles.length - 1; index >= 0; index--) {
258
258
  const style = styles[index];
259
259
  if (!style) continue;
260
- output = output.split(style.close).join(style.close + style.open);
260
+ if (output.includes(style.close)) output = output.split(style.close).join(style.close + style.open);
261
261
  opening = style.open + opening;
262
262
  closing += style.close;
263
263
  }
264
- output = output.replace(/\r?\n/g, match => `${closing}${match}${opening}`);
264
+ if (output.includes("\n")) output = output.replace(/\r?\n/g, match => `${closing}${match}${opening}`);
265
265
  return opening + output + closing;
266
266
  }
267
267
 
268
+ const BUILDER_CONTEXT = Symbol("chalk.context");
269
+ const BUILDER_STYLES = Symbol("chalk.styles");
270
+ const BUILDER_HEX_CACHE = Symbol("chalk.hexCache");
271
+ // Bounds per-builder hex children when callers derive colors dynamically.
272
+ const MAX_CACHED_HEX_BUILDERS = 256;
273
+
274
+ interface BuilderState {
275
+ [BUILDER_CONTEXT]: ChalkContext;
276
+ [BUILDER_STYLES]: readonly Style[];
277
+ [BUILDER_HEX_CACHE]?: Map<string, ChalkInstance>;
278
+ }
279
+
280
+ type Builder = ChalkInstance & BuilderState;
281
+
282
+ /** Child builders are created on first access and cached on the parent builder. */
283
+ function defineStyleGetter(proto: object, name: string, style: Style): void {
284
+ Object.defineProperty(proto, name, {
285
+ get(this: Builder) {
286
+ const child = createBuilder(this[BUILDER_CONTEXT], [...this[BUILDER_STYLES], style]);
287
+ Object.defineProperty(this, name, { value: child });
288
+ return child;
289
+ },
290
+ });
291
+ }
292
+
293
+ const builderPrototype: object = Object.create(Function.prototype);
294
+ Object.defineProperty(builderPrototype, "level", {
295
+ get(this: Builder) {
296
+ return this[BUILDER_CONTEXT].level;
297
+ },
298
+ set(this: Builder, level: number) {
299
+ this[BUILDER_CONTEXT].level = level;
300
+ },
301
+ });
302
+ for (const name in STYLES) {
303
+ const style = STYLES[name];
304
+ if (style) defineStyleGetter(builderPrototype, name, style);
305
+ }
306
+ defineStyleGetter(builderPrototype, "grey", STYLES.gray!);
307
+ defineStyleGetter(builderPrototype, "bgGray", STYLES.bgBlackBright!);
308
+ defineStyleGetter(builderPrototype, "bgGrey", STYLES.bgBlackBright!);
309
+ Object.defineProperty(builderPrototype, "hex", {
310
+ value(this: Builder, color: string): ChalkInstance {
311
+ const context = this[BUILDER_CONTEXT];
312
+ const key = `${context.level}:${color}`;
313
+ let cache = this[BUILDER_HEX_CACHE];
314
+ const cached = cache?.get(key);
315
+ if (cached) return cached;
316
+ const child = createBuilder(context, [...this[BUILDER_STYLES], hexStyle(color, context.level)]);
317
+ if (!cache) {
318
+ cache = new Map();
319
+ this[BUILDER_HEX_CACHE] = cache;
320
+ } else if (cache.size >= MAX_CACHED_HEX_BUILDERS) {
321
+ cache.clear();
322
+ }
323
+ cache.set(key, child);
324
+ return child;
325
+ },
326
+ });
327
+
268
328
  function createBuilder(context: ChalkContext, styles: readonly Style[]): ChalkInstance {
269
329
  const builder = ((...values: unknown[]) => {
270
330
  let text = "";
@@ -274,27 +334,10 @@ function createBuilder(context: ChalkContext, styles: readonly Style[]): ChalkIn
274
334
  }
275
335
  if (text.length === 0 || context.level === 0) return text;
276
336
  return applyStyles(text, styles);
277
- }) as ChalkInstance;
278
- Object.defineProperty(builder, "level", {
279
- get: () => context.level,
280
- set: (level: number) => {
281
- context.level = level;
282
- },
283
- });
284
- for (const name in STYLES) {
285
- const style = STYLES[name];
286
- if (style) Object.defineProperty(builder, name, { get: () => createBuilder(context, [...styles, style]) });
287
- }
288
- Object.defineProperty(builder, "grey", { get: () => createBuilder(context, [...styles, STYLES.gray!]) });
289
- Object.defineProperty(builder, "bgGray", {
290
- get: () => createBuilder(context, [...styles, STYLES.bgBlackBright!]),
291
- });
292
- Object.defineProperty(builder, "bgGrey", {
293
- get: () => createBuilder(context, [...styles, STYLES.bgBlackBright!]),
294
- });
295
- Object.defineProperty(builder, "hex", {
296
- value: (color: string) => createBuilder(context, [...styles, hexStyle(color, context.level)]),
297
- });
337
+ }) as Builder;
338
+ Object.setPrototypeOf(builder, builderPrototype);
339
+ builder[BUILDER_CONTEXT] = context;
340
+ builder[BUILDER_STYLES] = styles;
298
341
  return builder;
299
342
  }
300
343
 
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { archiveEntryText, readArchiveEntries } from "../ar";
3
+ import { ZipPackage } from "../ar";
4
4
  import { attribute, childElements, descendants, firstChild, localName, parseXml, type XmlElement } from "./xml";
5
5
 
6
6
  /** A mammoth-compatible diagnostic emitted while converting a document. */
@@ -26,6 +26,8 @@ export interface DocxImage {
26
26
  readonly altText: string;
27
27
  /** Read the image payload using mammoth's used encoding surface. */
28
28
  read(encoding: "base64"): Promise<string>;
29
+ /** Read the raw image bytes without a base64 round trip. */
30
+ readBytes(): Promise<Uint8Array>;
29
31
  }
30
32
 
31
33
  /** HTML attributes returned by a custom image converter. */
@@ -119,7 +121,8 @@ interface NumberingLevel {
119
121
  }
120
122
 
121
123
  interface ConversionContext {
122
- readonly entries: ReadonlyMap<string, Uint8Array>;
124
+ /** Lazily inflated DOCX members, capped on total inflated bytes. */
125
+ readonly package: ZipPackage;
123
126
  readonly relationships: ReadonlyMap<string, Relationship>;
124
127
  readonly contentTypes: ReadonlyMap<string, string>;
125
128
  readonly styles: ReadonlyMap<string, Style>;
@@ -373,8 +376,8 @@ async function renderImage(element: XmlElement, context: ConversionContext): Pro
373
376
  const relationship = relationshipId ? context.relationships.get(relationshipId) : undefined;
374
377
  if (!relationship || relationship.external) return "";
375
378
  const memberPath = relationshipPath(relationship.target);
376
- const bytes = context.entries.get(memberPath);
377
- if (!bytes) {
379
+ const docx = context.package;
380
+ if (!docx.members.has(memberPath)) {
378
381
  context.messages.push({ type: "warning", message: `Could not find image ${memberPath}` });
379
382
  return "";
380
383
  }
@@ -391,8 +394,12 @@ async function renderImage(element: XmlElement, context: ConversionContext): Pro
391
394
  altText,
392
395
  async read(encoding: "base64"): Promise<string> {
393
396
  if (encoding !== "base64") throw new Error(`Unsupported image encoding: ${encoding}`);
397
+ const bytes = await readImageMember(docx, memberPath);
394
398
  return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
395
399
  },
400
+ readBytes(): Promise<Uint8Array> {
401
+ return readImageMember(docx, memberPath);
402
+ },
396
403
  };
397
404
  const converted = await context.convertImage.convert(image);
398
405
  const attributes = Object.entries(converted)
@@ -403,6 +410,13 @@ async function renderImage(element: XmlElement, context: ConversionContext): Pro
403
410
  return attributes ? `<img ${attributes} />` : "<img />";
404
411
  }
405
412
 
413
+ /** Bytes of an image member already known to exist. */
414
+ async function readImageMember(docx: ZipPackage, memberPath: string): Promise<Uint8Array> {
415
+ const bytes = await docx.readBytes(memberPath);
416
+ if (!bytes) throw new Error(`Could not find image ${memberPath}`);
417
+ return bytes;
418
+ }
419
+
406
420
  function renderFootnoteReference(element: XmlElement, context: ConversionContext): string {
407
421
  const id = attribute(element, "w:id");
408
422
  if (!id || !context.footnotes.has(id)) return "";
@@ -655,21 +669,21 @@ function defaultImageConverter(): ImageConverter {
655
669
  /** Convert a DOCX buffer or path to mammoth-compatible HTML. */
656
670
  export async function convertToHtml(input: DocxInput, options: ConvertToHtmlOptions = {}): Promise<DocxResult> {
657
671
  const bytes = "buffer" in input && input.buffer ? input.buffer : await fs.readFile(input.path);
658
- const entries = await readArchiveEntries({ bytes, format: "zip" });
659
- const documentXml = archiveEntryText(entries, "word/document.xml");
672
+ const docx = await ZipPackage.open(bytes);
673
+ const documentXml = await docx.readText("word/document.xml");
660
674
  if (!documentXml) throw new Error("Invalid DOCX: missing word/document.xml");
661
675
  const context: ConversionContext = {
662
- entries,
663
- relationships: parseRelationships(archiveEntryText(entries, "word/_rels/document.xml.rels")),
664
- contentTypes: parseContentTypes(archiveEntryText(entries, "[Content_Types].xml")),
665
- styles: parseStyles(archiveEntryText(entries, "word/styles.xml")),
666
- numbering: parseNumbering(archiveEntryText(entries, "word/numbering.xml")),
676
+ package: docx,
677
+ relationships: parseRelationships(await docx.readText("word/_rels/document.xml.rels")),
678
+ contentTypes: parseContentTypes(await docx.readText("[Content_Types].xml")),
679
+ styles: parseStyles(await docx.readText("word/styles.xml")),
680
+ numbering: parseNumbering(await docx.readText("word/numbering.xml")),
667
681
  messages: [],
668
682
  warnedStyles: new Set(),
669
683
  customStyles: parseCustomStyles(options.styleMap),
670
684
  includeDefaultStyleMap: options.includeDefaultStyleMap !== false,
671
685
  convertImage: options.convertImage ?? defaultImageConverter(),
672
- footnotes: parseFootnotes(archiveEntryText(entries, "word/footnotes.xml")),
686
+ footnotes: parseFootnotes(await docx.readText("word/footnotes.xml")),
673
687
  usedFootnotes: [],
674
688
  footnoteOrdinals: new Map(),
675
689
  };