web-doc 0.9.0 → 0.10.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.
@@ -244,6 +244,41 @@ export interface TextLayoutLine {
244
244
  readonly fontSize: number;
245
245
  readonly color: string;
246
246
  }
247
+ /**
248
+ * The font a PDF text element is drawn in, as a browser can load it: a host
249
+ * typing over the element shows the text in the document's own glyphs and
250
+ * metrics. Exactly one of `face` and `missing` is set.
251
+ */
252
+ export interface TextFont {
253
+ readonly elementId: string;
254
+ /** The same for every element drawn in this font, so a host loads each face once. */
255
+ readonly key: string;
256
+ /** The family the file declares, as `textStyle.fontFamily` reports it. */
257
+ readonly family: string;
258
+ /**
259
+ * An OpenType or TrueType file for `FontFace`: a TrueType program with
260
+ * the OS/2, name and post tables a subset left out added, or a CFF program
261
+ * wrapped as OpenType with a Unicode cmap built from its glyph names (Adobe
262
+ * Glyph List names, `uniXXXX` and `uXXXX`). The program's own glyphs and
263
+ * widths are kept. Subset fonts hold only the glyphs the file uses, often
264
+ * not even a space, which the file places by positioning instead; a browser
265
+ * draws other characters in a fallback face. The file declares a
266
+ * regular face: its glyphs already carry the weight and slant, so load it
267
+ * with the default descriptors and do not ask the browser for bold or
268
+ * italic on top.
269
+ */
270
+ readonly face?: {
271
+ readonly data: Uint8Array;
272
+ readonly format: "opentype" | "truetype";
273
+ };
274
+ /**
275
+ * Why there is no face: the font is not embedded (a host shows it by name,
276
+ * as PDF readers do), or it is embedded in a form no browser loads: a
277
+ * CID-keyed CFF font, a Type 1 or Type 3 font, a font whose glyphs have no
278
+ * code points, or a program this reader cannot read.
279
+ */
280
+ readonly missing?: "not-embedded" | "cid-keyed" | "type1" | "no-unicode" | "unreadable";
281
+ }
247
282
  /** The drawn geometry of a `text`, `textBox` or `table` element. */
248
283
  export interface TextLayout {
249
284
  readonly elementId: string;
@@ -282,6 +317,11 @@ export interface PdfEditSession extends EditSessionBase<PdfOperation, PdfElement
282
317
  save(options?: PdfSaveOptions): Promise<SavedDocument>;
283
318
  /** Lines, glyph boxes and styles of a `text`, `textBox` or `table` element. */
284
319
  getTextLayout(elementId: string, options?: ReadOptions): Promise<ReadItem<TextLayout>>;
320
+ /**
321
+ * The font a `text` or `textBox` element is drawn in, as a browser loads it,
322
+ * or why it cannot be loaded; see `TextFont`.
323
+ */
324
+ getTextFont(elementId: string, options?: ReadOptions): Promise<ReadItem<TextFont>>;
285
325
  /** The text position nearest to a page-space point; none on a page without text. */
286
326
  positionAt(pageIndex: number, point: PagePoint, options?: ReadOptions): Promise<ReadItem<TextPosition>>;
287
327
  /** The rectangles a range covers, one per line fragment, in reading order. */
@@ -1,5 +1,5 @@
1
1
  import type { DescribeOptions, DocumentDescription, EditCheckpoint, OutlineOptions, OutlineResult, TargetCandidate, TargetQuery, ToolCall, ToolCallOptions, ToolResult, ToolSet } from "../ai/types.js";
2
- import type { EditSessionCore } from "../engine.js";
2
+ import type { EditSessionAccess, EditSessionCore } from "../engine.js";
3
3
  import type { ApplyOptions, AssetOptions, EditFindOptions, EditOperation, EditReceipt, EditState, ElementQuery, HistoryOptions, OperationSchemaSet, PagePoint, ReadItem, ReadOptions, ReadResult, SavedDocument, TextTarget } from "../types.js";
4
4
  import type { PptxDeleteElementOperation, PptxDeleteSlideOperation, PptxDuplicateSlideOperation, PptxEditSession, PptxElement, PptxFields, PptxInsertImageOperation, PptxInsertSlideOperation, PptxInsertTableOperation, PptxInsertTextBoxOperation, PptxLayoutInfo, PptxMoveElementOperation, PptxMoveSlideOperation, PptxOperation, PptxReplaceTextOperation, PptxResizeElementOperation, PptxSaveOptions, PptxSetShapeStyleOperation, PptxSetTableCellOperation, PptxSetTextStyleOperation, PptxSlideInfo } from "./types.js";
5
5
  /**
@@ -10,7 +10,7 @@ import type { PptxDeleteElementOperation, PptxDeleteSlideOperation, PptxDuplicat
10
10
  export declare class PptxSession implements PptxEditSession {
11
11
  #private;
12
12
  readonly format: "pptx";
13
- constructor(core: EditSessionCore);
13
+ constructor(core: EditSessionCore, access?: EditSessionAccess);
14
14
  get sessionId(): string;
15
15
  get state(): EditState;
16
16
  get schemas(): OperationSchemaSet;
@@ -24,6 +24,11 @@ export declare class PptxSession implements PptxEditSession {
24
24
  addAsset(data: Uint8Array, options?: AssetOptions): Promise<string>;
25
25
  getElements(query?: ElementQuery, options?: ReadOptions): Promise<ReadResult<PptxElement>>;
26
26
  getElement(id: string, options?: ReadOptions): Promise<ReadItem<PptxElement>>;
27
+ /**
28
+ * The elements under a point, top-most first: those whose frame holds it,
29
+ * and before them a shape whose text is painted there past its frame, as
30
+ * text wrapped below a short box is.
31
+ */
27
32
  elementsAt(pageIndex: number, point: PagePoint, options?: ReadOptions): Promise<ReadResult<PptxElement>>;
28
33
  findText(query: string, options?: EditFindOptions): Promise<ReadResult<TextTarget>>;
29
34
  getOutline(options?: OutlineOptions): Promise<OutlineResult>;
@@ -11,8 +11,11 @@ export class PptxSession {
11
11
  format = "pptx";
12
12
  #core;
13
13
  #tools;
14
- constructor(core) {
14
+ /** The shown deck's text runs; absent in a headless session, which hits frames only. */
15
+ #access;
16
+ constructor(core, access) {
15
17
  this.#core = core;
18
+ this.#access = access;
16
19
  }
17
20
  get sessionId() {
18
21
  return this.#core.sessionId;
@@ -53,8 +56,31 @@ export class PptxSession {
53
56
  getElement(id, options) {
54
57
  return this.#core.getElement(id, options);
55
58
  }
56
- elementsAt(pageIndex, point, options) {
57
- return this.#core.elementsAt(pageIndex, point, options);
59
+ /**
60
+ * The elements under a point, top-most first: those whose frame holds it,
61
+ * and before them a shape whose text is painted there past its frame, as
62
+ * text wrapped below a short box is.
63
+ */
64
+ async elementsAt(pageIndex, point, options) {
65
+ const framed = (await this.#core.elementsAt(pageIndex, point, options));
66
+ const access = this.#access;
67
+ if (!access)
68
+ return framed;
69
+ const origins = (await access.getTextRuns(pageIndex, options?.signal))
70
+ .filter((run) => run.shapeOrigin && runHolds(run, point))
71
+ .map((run) => run.shapeOrigin);
72
+ if (origins.length === 0)
73
+ return framed;
74
+ const all = (await this.getElements({ pageIndex }, options)).items;
75
+ const painted = all.filter((element) => element.text !== undefined &&
76
+ !framed.items.some((hit) => hit.id === element.id) &&
77
+ origins.some((origin) => originOf(element, origin)));
78
+ if (painted.length === 0)
79
+ return framed;
80
+ return Object.freeze({
81
+ ...framed,
82
+ items: Object.freeze([...painted, ...framed.items]),
83
+ });
58
84
  }
59
85
  findText(query, options) {
60
86
  return this.#core.findText(query, options);
@@ -143,3 +169,15 @@ function pptxReads(engine) {
143
169
  throw new ViewerError("edit-unsupported", "The engine does not provide the PPTX reads", { details: { format: "pptx", reason: "no-reads" } });
144
170
  return candidate;
145
171
  }
172
+ /** Whether a run's box holds a point. */
173
+ function runHolds(run, point) {
174
+ return (point.x >= run.x &&
175
+ point.x <= run.x + run.width &&
176
+ point.y >= run.y &&
177
+ point.y <= run.y + run.height);
178
+ }
179
+ /** Whether a shape's frame starts where a run says its shape does, to half a pixel. */
180
+ function originOf(element, origin) {
181
+ const frame = element.frame ?? element.bounds;
182
+ return (Math.abs(frame.x - origin.x) <= 0.5 && Math.abs(frame.y - origin.y) <= 0.5);
183
+ }
package/dist/viewport.js CHANGED
@@ -156,6 +156,8 @@ export class ViewerViewport {
156
156
  }
157
157
  replaceDocument(info, changedPages, revision) {
158
158
  const previous = this.#info;
159
+ const painted = this.#painted.get(this.#contentRevision);
160
+ this.#painted.clear();
159
161
  this.#info = info;
160
162
  this.#contentRevision = revision ?? this.#contentRevision + 1;
161
163
  // Only pages whose content or size changed get a new render key; the
@@ -172,9 +174,15 @@ export class ViewerViewport {
172
174
  const size = info.pageSizes?.[pageIndex];
173
175
  const before = previous?.pageSizes?.[pageIndex];
174
176
  const resized = size?.width !== before?.width || size?.height !== before?.height;
175
- if (pageIndex >= from || changed.has(pageIndex) || resized)
177
+ if (pageIndex >= from || changed.has(pageIndex) || resized) {
176
178
  this.#pageRevisions[pageIndex] =
177
179
  (this.#pageRevisions[pageIndex] ?? 0) + 1;
180
+ }
181
+ else if (painted?.has(pageIndex) && this.#slots.has(pageIndex)) {
182
+ // A completed, retained page is still current. Carry its queued
183
+ // notification forward instead of losing it at the next frame.
184
+ this.#markPainted(pageIndex, this.#contentRevision);
185
+ }
178
186
  }
179
187
  // Slots stay mounted and repaint in place; the browser clamps the scroll
180
188
  // position itself once the spacer takes the new document's height.
@@ -371,6 +379,8 @@ export class ViewerViewport {
371
379
  slot.controller?.abort();
372
380
  slot.root.remove();
373
381
  this.#slots.delete(pageIndex);
382
+ for (const pages of this.#painted.values())
383
+ pages.delete(pageIndex);
374
384
  }
375
385
  for (let pageIndex = range.start; pageIndex < range.end; pageIndex += 1) {
376
386
  const slot = this.#slots.get(pageIndex) ?? this.#createSlot(pageIndex);
@@ -443,41 +453,86 @@ export class ViewerViewport {
443
453
  slot.controller = controller;
444
454
  const generation = ++slot.generation;
445
455
  const zoom = this.#host.state.zoom;
456
+ const devicePixelRatio = window.devicePixelRatio || 1;
457
+ const pageRevision = this.#pageRevisions[pageIndex] ?? 0;
446
458
  const contentRevision = this.#contentRevision;
459
+ // Adapters resize and paint their target asynchronously. Never give them
460
+ // the visible canvas: resizing clears it before the next frame is ready.
461
+ const canvas = document.createElement("canvas");
462
+ const textLayer = document.createElement("div");
463
+ const highlightLayer = document.createElement("div");
464
+ textLayer.style.cssText = slot.textLayer.style.cssText;
465
+ highlightLayer.style.cssText = slot.highlightLayer.style.cssText;
466
+ const isCurrent = () => !this.#destroyed &&
467
+ !controller.signal.aborted &&
468
+ this.#slots.get(pageIndex) === slot &&
469
+ slot.controller === controller &&
470
+ slot.generation === generation &&
471
+ (this.#pageRevisions[pageIndex] ?? 0) === pageRevision &&
472
+ this.#host.state.zoom === zoom &&
473
+ (window.devicePixelRatio || 1) === devicePixelRatio;
474
+ let rendering;
447
475
  try {
448
- let renderError;
449
- const rendering = this.#host
450
- .renderPage(pageIndex, slot.canvas, {
476
+ rendering = this.#host.renderPage(pageIndex, canvas, {
451
477
  zoom,
452
- devicePixelRatio: window.devicePixelRatio || 1,
478
+ devicePixelRatio,
453
479
  priority: pageIndex === this.#host.state.pageIndex ? "visible" : "adjacent",
454
480
  signal: controller.signal,
481
+ });
482
+ const preparingText = this.#host
483
+ .getTextRuns(pageIndex, controller.signal)
484
+ .then(async (runs) => {
485
+ await rendering;
486
+ if (!isCurrent())
487
+ return;
488
+ await this.#buildTextLayers(textLayer, highlightLayer, pageIndex, runs, zoom, controller.signal);
455
489
  })
456
- .catch((error) => {
457
- renderError = error;
490
+ .then(() => true, (error) => {
491
+ if (isCurrent())
492
+ slot.root.dataset.renderError =
493
+ error instanceof Error ? error.message : String(error);
494
+ // A valid raster still publishes without unavailable geometry.
495
+ // Never retain partial or previous text/highlight hit targets.
496
+ for (const layer of [textLayer, highlightLayer]) {
497
+ layer.replaceChildren();
498
+ resetOverlay(layer);
499
+ }
500
+ return false;
458
501
  });
459
- const runs = await this.#host.getTextRuns(pageIndex, controller.signal);
460
- if (controller.signal.aborted || slot.generation !== generation)
461
- return;
462
- await this.#buildTextLayers(slot.textLayer, slot.highlightLayer, pageIndex, runs, zoom, controller.signal);
463
- if (controller.signal.aborted || slot.generation !== generation)
464
- return;
465
- await rendering;
466
- if (renderError)
467
- throw renderError;
468
- if (controller.signal.aborted || slot.generation !== generation)
502
+ const [, textReady] = await Promise.all([rendering, preparingText]);
503
+ if (!isCurrent())
469
504
  return;
470
- const cssWidth = slot.canvas.width / (window.devicePixelRatio || 1);
471
- const cssHeight = slot.canvas.height / (window.devicePixelRatio || 1);
505
+ const context = slot.canvas.getContext("2d");
506
+ if (!context)
507
+ throw new Error("Canvas 2D context is unavailable");
508
+ // Publish the completed raster and its matching text geometry in one
509
+ // synchronous commit, keeping the mounted page and layer identities.
510
+ slot.canvas.width = canvas.width;
511
+ slot.canvas.height = canvas.height;
512
+ context.drawImage(canvas, 0, 0);
513
+ replaceTextLayer(slot.textLayer, textLayer);
514
+ replaceTextLayer(slot.highlightLayer, highlightLayer);
515
+ const cssWidth = canvas.width / devicePixelRatio;
516
+ const cssHeight = canvas.height / devicePixelRatio;
472
517
  slot.root.style.width = `${cssWidth}px`;
473
518
  slot.root.style.height = `${cssHeight}px`;
519
+ if (textReady)
520
+ delete slot.root.dataset.renderError;
474
521
  this.#markPainted(pageIndex, contentRevision);
475
522
  }
476
523
  catch (error) {
477
- if (!controller.signal.aborted) {
524
+ if (isCurrent()) {
478
525
  slot.root.dataset.renderError =
479
526
  error instanceof Error ? error.message : String(error);
480
527
  }
528
+ controller.abort();
529
+ }
530
+ finally {
531
+ // A cancelled adapter may still be writing. Release its backing store
532
+ // only once it has stopped, without affecting the committed page.
533
+ await rendering?.catch(() => undefined);
534
+ canvas.width = 0;
535
+ canvas.height = 0;
481
536
  }
482
537
  }
483
538
  async #buildTextLayers(textLayer, highlightLayer, pageIndex, runs, zoom, signal) {
@@ -817,6 +872,7 @@ export class ViewerViewport {
817
872
  });
818
873
  }
819
874
  #clearSlots() {
875
+ this.#painted.clear();
820
876
  for (const slot of this.#slots.values()) {
821
877
  slot.controller?.abort();
822
878
  slot.root.remove();
@@ -824,6 +880,13 @@ export class ViewerViewport {
824
880
  this.#slots.clear();
825
881
  }
826
882
  }
883
+ function replaceTextLayer(target, source) {
884
+ const children = document.createDocumentFragment();
885
+ while (source.firstChild)
886
+ children.append(source.firstChild);
887
+ target.style.cssText = source.style.cssText;
888
+ target.replaceChildren(children);
889
+ }
827
890
  function naturalPageSize(info, pageIndex) {
828
891
  const candidate = info?.pageSizes?.[pageIndex];
829
892
  return candidate &&
@@ -2,7 +2,7 @@ import type { DocumentFormat, DocumentInfo, ResourceLimits, TextRun, ViewerError
2
2
  import type { EditableFormat } from "./edit/types.js";
3
3
  export type DocumentWorkerOperation = "init" | "open" | "get-info" | "render" | "get-text-map" | "close" | "destroy";
4
4
  /** Operations of an edit engine worker; payloads mirror the engine interface. */
5
- export type EditWorkerOperation = "edit-init" | "edit-open" | "edit-validate" | "edit-apply" | "edit-materialize" | "edit-restore" | "edit-put-asset" | "edit-elements" | "edit-element" | "edit-elements-at" | "edit-find-text" | "edit-text-layout" | "edit-position-at" | "edit-range-rects" | "edit-render-without" | "edit-page-layout" | "edit-pptx-slides" | "edit-pptx-layouts" | "edit-docx-revisions" | "edit-dispose";
5
+ export type EditWorkerOperation = "edit-init" | "edit-open" | "edit-validate" | "edit-apply" | "edit-materialize" | "edit-restore" | "edit-put-asset" | "edit-elements" | "edit-element" | "edit-elements-at" | "edit-find-text" | "edit-text-layout" | "edit-text-font" | "edit-position-at" | "edit-range-rects" | "edit-render-without" | "edit-page-layout" | "edit-pptx-slides" | "edit-pptx-layouts" | "edit-docx-revisions" | "edit-dispose";
6
6
  export type WorkerOperation = DocumentWorkerOperation | EditWorkerOperation;
7
7
  export interface WorkerRequest {
8
8
  readonly kind: "request";