@linxiraos/pi-utils 1.1.27 → 1.2.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 (59) hide show
  1. package/CHANGELOG.md +4 -6
  2. package/README.md +1 -1
  3. package/dist/types/ar/index.d.ts +1 -0
  4. package/dist/types/ar/zip-package.d.ts +21 -0
  5. package/dist/types/color.d.ts +2 -0
  6. package/dist/types/dirs.d.ts +31 -12
  7. package/dist/types/docx/converter.d.ts +2 -0
  8. package/dist/types/dom/core.d.ts +15 -4
  9. package/dist/types/dom/parser.d.ts +5 -3
  10. package/dist/types/dom/selector.d.ts +7 -1
  11. package/dist/types/logger/rotating-file.d.ts +4 -2
  12. package/dist/types/loop-phase.d.ts +12 -7
  13. package/dist/types/marked/core.d.ts +7 -6
  14. package/dist/types/marked-list.d.ts +38 -0
  15. package/dist/types/math-delimiters.d.ts +10 -0
  16. package/dist/types/readability/types.d.ts +2 -0
  17. package/dist/types/temp.d.ts +7 -0
  18. package/dist/types/turndown/types.d.ts +7 -0
  19. package/dist/types/vterm/buffer.d.ts +16 -5
  20. package/package.json +2 -2
  21. package/src/ar/index.ts +1 -0
  22. package/src/ar/open.ts +29 -8
  23. package/src/ar/zip-package.ts +57 -0
  24. package/src/chalk.ts +66 -23
  25. package/src/color.ts +6 -0
  26. package/src/dirs.ts +84 -24
  27. package/src/docx/converter.ts +26 -12
  28. package/src/dom/core.ts +354 -111
  29. package/src/dom/parser.ts +42 -19
  30. package/src/dom/selector.ts +328 -95
  31. package/src/fetch-retry.ts +2 -0
  32. package/src/format.ts +5 -1
  33. package/src/json-lexer.ts +54 -0
  34. package/src/json-parse.ts +21 -6
  35. package/src/logger/rotating-file.ts +5 -3
  36. package/src/logger.ts +39 -23
  37. package/src/loop-phase.ts +66 -21
  38. package/src/marked/core.ts +42 -18
  39. package/src/marked-list.ts +73 -0
  40. package/src/math-delimiters.ts +16 -0
  41. package/src/module-timer.ts +5 -5
  42. package/src/postmortem.ts +1 -1
  43. package/src/process-name.ts +2 -2
  44. package/src/prompt.ts +3 -1
  45. package/src/ptree.ts +28 -35
  46. package/src/readability/readability.ts +349 -39
  47. package/src/readability/types.ts +2 -0
  48. package/src/runtime-install.ts +2 -2
  49. package/src/stream.ts +34 -13
  50. package/src/temp.ts +7 -0
  51. package/src/template.ts +7 -4
  52. package/src/timing-buffer.ts +1 -1
  53. package/src/tls-fetch.ts +1 -1
  54. package/src/turndown/gfm.ts +16 -5
  55. package/src/turndown/html.ts +28 -8
  56. package/src/turndown/service.ts +43 -15
  57. package/src/turndown/types.ts +7 -0
  58. package/src/vterm/buffer.ts +31 -11
  59. package/src/vterm/terminal.ts +213 -100
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
 
package/src/color.ts CHANGED
@@ -399,6 +399,12 @@ export function oklchCusp(h: number): { l: number; c: number } {
399
399
  return { l: lCusp, c: lCusp * sCusp };
400
400
  }
401
401
 
402
+ /** Angular distance between two hues in degrees (0–180), wrapping at 360. */
403
+ export function hueDistance(a: number, b: number): number {
404
+ const d = Math.abs(a - b) % 360;
405
+ return Math.min(d, 360 - d);
406
+ }
407
+
402
408
  /** Slack allowed on linear channels before a color counts as out of sRGB gamut. */
403
409
  const GAMUT_EPSILON = 1e-4;
404
410
 
package/src/dirs.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Centralized path helpers for zeta-c config directories.
2
+ * Centralized path helpers for zetacode config directories.
3
3
  *
4
4
  * Uses PI_CONFIG_DIR (default ".zeta") for the config root and
5
+ * Uses ZETA_CONFIG_DIR (default ".zeta") for the config root and
5
6
  * ZETA_CODING_AGENT_DIR to override the agent directory.
6
7
  *
7
8
  * On Linux, if XDG_DATA_HOME / XDG_STATE_HOME / XDG_CACHE_HOME environment
8
9
  * variables are set, paths are redirected to XDG-compliant locations under
9
- * $XDG_*_HOME/zeta/. This requires running `zeta-c config migrate` first to
10
+ * $XDG_*_HOME/zeta/. This requires running `zetacode config migrate` first to
10
11
  * move data to the new locations. No filesystem existence checks are performed
11
12
  * — if the env var is set, zeta trusts that the migration has been done.
12
13
  */
@@ -22,11 +23,11 @@ import { isEnoent, isEnotdir } from "./fs-error";
22
23
  * names, provider attribution headers, splash wordmark. NOT the CLI command. */
23
24
  export const APP_NAME: string = "zeta";
24
25
 
25
- /** CLI binary name (`npm i -g @linxiraos/zeta` installs `zeta-c`, with
26
- * `zeta-cli` and `zetacode` as aliases). Every user-facing "run this command"
27
- * string and process title uses this; the bare `zeta` name belongs to the
28
- * @linxiraos/main workspace product, not this package. */
29
- export const CLI_BIN_NAME: string = "zeta-c";
26
+ /** CLI binary name (`npm i -g @linxiraos/zeta` installs `zetacode`, with
27
+ * `zetacode` and `zeta-cli` as compatibility aliases). Every user-facing "run
28
+ * this command" string and process title uses this; the bare `zeta` name
29
+ * belongs to the @linxiraos/main workspace product, not this package. */
30
+ export const CLI_BIN_NAME: string = "zetacode";
30
31
 
31
32
  /** Public homepage that inference gateways (OpenRouter, Vercel AI Gateway) credit our traffic to. */
32
33
  export const APP_URL: string = "https://linxira-os.github.io/zeta/";
@@ -125,6 +126,14 @@ function getProfileConfigRoot(profile: string | undefined): string {
125
126
  return profile ? path.join(root, "profiles", profile) : root;
126
127
  }
127
128
 
129
+ function readPiProfileFromEnvSafe(): string | undefined {
130
+ try {
131
+ return normalizeProfileName(process.env.ZETA_PROFILE);
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+
128
137
  function getProfileAgentDir(profile: string): string {
129
138
  return path.join(getProfileConfigRoot(profile), "agent");
130
139
  }
@@ -299,9 +308,9 @@ export function getSafeProjectCwd(): string {
299
308
  return os.homedir();
300
309
  }
301
310
 
302
- /** Get the config directory name relative to home (e.g. ".zeta" or PI_CONFIG_DIR override). */
311
+ /** Get the config directory name relative to home (e.g. ".zeta" or ZETA_CONFIG_DIR override). */
303
312
  export function getConfigDirName(): string {
304
- return process.env.PI_CONFIG_DIR || CONFIG_DIR_NAME;
313
+ return process.env.ZETA_CONFIG_DIR || CONFIG_DIR_NAME;
305
314
  }
306
315
 
307
316
  /** Get the config agent directory name relative to home (e.g. ".zeta/agent" or PI_CONFIG_DIR + "/agent"). */
@@ -345,7 +354,7 @@ class DirResolver {
345
354
  const isDefault = this.agentDir === defaultAgent;
346
355
 
347
356
  // XDG is a Linux convention. On supported platforms, default profile state
348
- // resolves under $XDG_*_HOME/zeta once `zeta-c config init-xdg` has migrated
357
+ // resolves under $XDG_*_HOME/zeta once `zetacode config init-xdg` has migrated
349
358
  // the user's data. Named profiles follow a stricter rule: the XDG choice
350
359
  // is keyed on the profile-specific XDG path, never the base app root.
351
360
  //
@@ -454,8 +463,12 @@ class DirResolver {
454
463
  * without exporting it). Returns `undefined` in those cases so reset falls back
455
464
  * to the standard `~/.zeta/agent`.
456
465
  */
457
- function resolvePreProfileAgentDir(profile: string | undefined, agentDirEnv: string | undefined): string | undefined {
458
- return isProfileDerivedAgentDir(profile, agentDirEnv) ? undefined : agentDirEnv;
466
+ function resolvePreProfileAgentDir(
467
+ profile: string | undefined,
468
+ agentDirEnv: string | undefined,
469
+ profileAgentDirSource: string | undefined = profile,
470
+ ): string | undefined {
471
+ return isProfileDerivedAgentDir(profile ?? profileAgentDirSource, agentDirEnv) ? undefined : agentDirEnv;
459
472
  }
460
473
 
461
474
  let activeProfile = readProfileFromEnvSafe();
@@ -478,6 +491,9 @@ function resolveActiveAgentDirOverride(): string | undefined {
478
491
  const env = process.env.ZETA_CODING_AGENT_DIR;
479
492
  if (env && /[/\\]profiles[/\\][^/\\]+[/\\]agent$/.test(env.replace(/\\/g, "/"))) return undefined;
480
493
  return resolvePreProfileAgentDir(undefined, env);
494
+ return activeProfile
495
+ ? undefined
496
+ : resolvePreProfileAgentDir(undefined, process.env.ZETA_CODING_AGENT_DIR, readPiProfileFromEnvSafe());
481
497
  }
482
498
 
483
499
  let dirs = new DirResolver({
@@ -495,7 +511,11 @@ let dirs = new DirResolver({
495
511
  * — and refreshed on `setAgentDir`, since that call is the user explicitly
496
512
  * redefining the baseline.
497
513
  */
498
- let preProfileAgentDirEnv: string | undefined = resolveActiveAgentDirOverride();
514
+ let preProfileAgentDirEnv: string | undefined = resolvePreProfileAgentDir(
515
+ activeProfile,
516
+ process.env.ZETA_CODING_AGENT_DIR,
517
+ activeProfile ?? readPiProfileFromEnvSafe(),
518
+ );
499
519
  // Anchor home for the resolver. Captured at module load to stay stable across
500
520
  // test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so
501
521
  // production callers (`home === RESOLVER_HOME`) hit the XDG-aware resolver while
@@ -548,7 +568,11 @@ export function setAgentDir(dir: string): void {
548
568
  * no business clearing it.
549
569
  */
550
570
  export function __resetProfileSnapshotForTests(): void {
551
- preProfileAgentDirEnv = resolveActiveAgentDirOverride();
571
+ preProfileAgentDirEnv = resolvePreProfileAgentDir(
572
+ activeProfile,
573
+ process.env.ZETA_CODING_AGENT_DIR,
574
+ activeProfile ?? readPiProfileFromEnvSafe(),
575
+ );
552
576
  }
553
577
 
554
578
  /**
@@ -573,11 +597,18 @@ export function setProfile(profile: string | undefined): void {
573
597
  // snapshot — the "pre-profile" baseline is the state before profiles
574
598
  // entered the picture, not the state between two activations.
575
599
  preProfileAgentDirEnv = resolveActiveAgentDirOverride();
600
+ preProfileAgentDirEnv = resolvePreProfileAgentDir(
601
+ undefined,
602
+ process.env.ZETA_CODING_AGENT_DIR,
603
+ readPiProfileFromEnvSafe(),
604
+ );
576
605
  }
577
606
  activeProfile = next;
578
607
  if (activeProfile) {
579
608
  dirs = new DirResolver({ profile: activeProfile });
580
609
  process.env.ZETA_PROFILE = activeProfile;
610
+ process.env.OMP_PROFILE = activeProfile;
611
+ process.env.PI_PROFILE = activeProfile;
581
612
  process.env.ZETA_CODING_AGENT_DIR = dirs.agentDir;
582
613
  } else {
583
614
  for (const key of PROFILE_ENV_KEYS) {
@@ -669,13 +700,26 @@ export function getPluginsPackageJson(home?: string): string {
669
700
  return path.join(getPluginsDir(home), "package.json");
670
701
  }
671
702
 
672
- /** Plugin lock file (~/.zeta/plugins/omp-plugins.lock.json). The on-disk
673
- * filename stays upstream-compatible so existing installs keep their runtime
674
- * state (enabled features, versions) across upgrades — never rebrand it. */
703
+ /**
704
+ * Plugin lock file (~/.zeta/plugins/omp-plugins.lock.json).
705
+ *
706
+ * The on-disk filename stays upstream-compatible: it carries per-plugin runtime
707
+ * state (enabled flag, version), so renaming it would reset every existing
708
+ * install's plugins on upgrade. That is the reason for the compat, not a
709
+ * general rule — see `document/brand-surface-rules.md`, which classifies this
710
+ * surface as DUAL (read both spellings, write the Zeta one) rather than
711
+ * permanently frozen.
712
+ */
675
713
  export function getPluginsLockfile(home?: string): string {
676
714
  return path.join(getPluginsDir(home), "omp-plugins.lock.json");
677
715
  }
678
716
 
717
+ /** Zeta-named lockfile, written going forward; `getPluginsLockfile` is read first. */
718
+ export const PLUGINS_LOCKFILE_CANONICAL = "zeta-plugins.lock.json";
719
+
720
+ /** Pre-rename lockfile name still produced by earlier installs. */
721
+ export const PLUGINS_LOCKFILE_LEGACY = "omp-plugins.lock.json";
722
+
679
723
  /**
680
724
  * Normalize a plugin id into a safe single path segment (spec §4.2): lowercased,
681
725
  * every character outside `[a-z0-9._-]` (including `/` and `\`, so scoped npm
@@ -762,11 +806,17 @@ export function getRemoteDir(): string {
762
806
  * empty/whitespace input or a path that is still relative after expansion.
763
807
  *
764
808
  * A worktree base is process-global and consumed by both creation
765
- * (PR checkout, task isolation) and cleanup (`zeta worktree`). A relative value
809
+ * (PR checkout, task isolation) and cleanup (`zetacode worktree`). A relative value
766
810
  * would resolve against whatever cwd happened to launch `zeta`, so checkout and
767
811
  * cleanup could disagree — we refuse it rather than silently bind it to cwd.
812
+ * Worktree bases and the natives directory are process-global: a worktree base
813
+ * is consumed by both creation (PR checkout, task isolation) and cleanup
814
+ * (`zetacode worktree`), and every launch extracts or loads the native addon from
815
+ * the same natives directory. A relative value would resolve against whatever
816
+ * cwd happened to launch `zetacode`, so those readers could disagree — we refuse it
817
+ * rather than silently bind it to cwd.
768
818
  */
769
- function resolveWorktreeBase(value: string | undefined): string | undefined {
819
+ function resolveAbsoluteDir(value: string | undefined): string | undefined {
770
820
  const trimmed = value?.trim();
771
821
  if (!trimmed) return undefined;
772
822
  let p = trimmed;
@@ -779,17 +829,17 @@ let worktreesDirOverride: string | undefined;
779
829
 
780
830
  /**
781
831
  * Relocate the base directory for agent-managed worktrees (PR checkouts, task
782
- * isolation, and `zeta worktree` cleanup all read the same base). Driven by the
832
+ * isolation, and `zetacode worktree` cleanup all read the same base). Driven by the
783
833
  * `worktree.base` setting in coding-agent; pass `undefined`/empty to clear and
784
834
  * fall back to `OMP_WORKTREE_DIR` or the `~/.zeta/wt` default.
785
835
  *
786
- * `~` is expanded and a relative path is rejected (see {@link resolveWorktreeBase}).
836
+ * `~` is expanded and a relative path is rejected (see {@link resolveAbsoluteDir}).
787
837
  * Returns the absolute path that took effect, or `undefined` if the input was
788
838
  * cleared or rejected — callers can warn on a non-empty input that returns
789
839
  * `undefined`.
790
840
  */
791
841
  export function setWorktreesDir(dir: string | undefined): string | undefined {
792
- worktreesDirOverride = resolveWorktreeBase(dir);
842
+ worktreesDirOverride = resolveAbsoluteDir(dir);
793
843
  return worktreesDirOverride;
794
844
  }
795
845
 
@@ -801,7 +851,7 @@ export function setWorktreesDir(dir: string | undefined): string | undefined {
801
851
  * ignored and resolution falls through.
802
852
  */
803
853
  export function getWorktreesDir(): string {
804
- return resolveWorktreeBase(process.env.OMP_WORKTREE_DIR) ?? worktreesDirOverride ?? dirs.rootSubdir("wt", "data");
854
+ return resolveAbsoluteDir(process.env.OMP_WORKTREE_DIR) ?? worktreesDirOverride ?? dirs.rootSubdir("wt", "data");
805
855
  }
806
856
 
807
857
  /** Get the SSH control socket directory (~/.zeta/ssh-control). */
@@ -928,8 +978,18 @@ export function getFastembedRuntimeDir(): string {
928
978
  }
929
979
 
930
980
  /** Get the natives directory (~/.zeta/natives). */
981
+ /**
982
+ * Get the directory the native addon loads downloaded tree-sitter wasm
983
+ * grammars from (`<natives dir>/grammars`, `~/.zeta/natives/grammars`). The
984
+ * natives loader configures the addon with the same path.
985
+ */
986
+ export function getNativeGrammarsDir(): string {
987
+ return path.join(getNativesDir(), "grammars");
988
+ }
989
+
990
+ /** Get the natives directory. PI_NATIVES_DIR overrides the usual cache root; relative values are ignored. */
931
991
  export function getNativesDir(): string {
932
- return dirs.rootSubdir("natives", "cache");
992
+ return resolveAbsoluteDir(process.env.PI_NATIVES_DIR) ?? dirs.rootSubdir("natives", "cache");
933
993
  }
934
994
 
935
995
  /** Get the stats database path (~/.zeta/stats.db). */
@@ -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
  };