papilot 0.3.0 → 0.4.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.
@@ -210,13 +210,13 @@ export async function workingTree(root: string) {
210
210
  }
211
211
 
212
212
  async function engine() {
213
- const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter }] = await Promise.all([
213
+ const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter, walkText }] = await Promise.all([
214
214
  import(pathToFileURL(path.join(ENGINE, 'src/core/build.ts')).href),
215
215
  import(pathToFileURL(path.join(ENGINE, 'src/core/git.ts')).href),
216
216
  import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href),
217
217
  import(pathToFileURL(path.join(ENGINE, 'src/render/pdf.ts')).href),
218
218
  ]);
219
- return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter };
219
+ return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter, walkText };
220
220
  }
221
221
 
222
222
  // `synctex edit` maps a PDF position (big points from the top-left corner) to
@@ -293,7 +293,7 @@ export function sourceExcerpt(file: string, [start, end]: [number, number], limi
293
293
  // the page's top left. pdf.js lays a whole line out in a stand-in font and
294
294
  // drifts from the printed words mid-line; per-word runs cannot drift.
295
295
  export async function pdfWords(pdf: string, page: number) {
296
- const { PdfDocument } = await engine();
296
+ const { PdfDocument, walkText } = await engine();
297
297
  const document = new PdfDocument(pdf);
298
298
  try {
299
299
  if (!Number.isInteger(page) || page < 0 || page >= document.length) throw Object.assign(new Error('That page is not part of the manuscript.'), { statusCode: 400 });
@@ -301,7 +301,7 @@ export async function pdfWords(pdf: string, page: number) {
301
301
  const words: { text: string; left: number; right: number; baseline: number; size: number; eol: boolean }[] = [];
302
302
  let word: (typeof words)[number] | null = null;
303
303
  const text = raw.toStructuredText('preserve-whitespace');
304
- text.walk({
304
+ walkText(text, {
305
305
  beginLine: () => { word = null; },
306
306
  onChar: (c: string, origin: [number, number], _font: unknown, size: number, quad: number[]) => {
307
307
  if (/\s/u.test(c)) { word = null; return; }
@@ -8,4 +8,8 @@ export const VERSION: string = JSON.parse(fs.readFileSync(path.join(ROOT, 'packa
8
8
  export const PORT = Number(process.env.PAPILOT_PORT || 7274);
9
9
  export const PUBLIC_ORIGIN = process.env.PAPILOT_PUBLIC_ORIGIN || '';
10
10
  export const OWNER = process.env.PAPILOT_TAILSCALE_LOGIN || '';
11
+ // The public URL where invited people sign in with passkeys, and the local
12
+ // port its proxy (such as Tailscale Funnel) forwards to.
13
+ export const ACCOUNT_ORIGIN = (process.env.PAPILOT_ACCOUNT_ORIGIN || '').replace(/\/+$/, '');
14
+ export const ACCOUNT_PORT = Number(process.env.PAPILOT_ACCOUNT_PORT || 7275);
11
15
  export const CODEX = process.env.PAPILOT_CODEX_BIN || (fs.existsSync(path.join(ROOT, 'node_modules/.bin/codex')) ? path.join(ROOT, 'node_modules/.bin/codex') : 'codex');
@@ -13,3 +13,23 @@ export function authorize(headers: IncomingHttpHeaders, remoteAddress: string |
13
13
  if (headers.origin && headers.origin !== publicOrigin) return 'The request origin does not match.';
14
14
  return null;
15
15
  }
16
+
17
+ // The public sign-in URL needs its own port, an exact HTTPS origin (plain HTTP
18
+ // only on localhost, for testing), and must not be the tailnet endpoint, whose
19
+ // requests are trusted as the owner.
20
+ export function accountProblem(accountOrigin: string, accountPort: number, port: number, publicOrigin: string): string | null {
21
+ if (!accountOrigin) return null;
22
+ let url: URL;
23
+ try { url = new URL(accountOrigin); } catch { return `PAPILOT_ACCOUNT_ORIGIN is not a URL: ${accountOrigin}`; }
24
+ if (url.origin !== accountOrigin) return `PAPILOT_ACCOUNT_ORIGIN must be an origin without a path, such as ${url.origin}.`;
25
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost')) return 'PAPILOT_ACCOUNT_ORIGIN must use https://.';
26
+ if (!Number.isInteger(accountPort) || accountPort < 1 || accountPort > 65535 || accountPort === port) return `PAPILOT_ACCOUNT_PORT must be a free port other than ${port}.`;
27
+ if (publicOrigin && new URL(publicOrigin).host === url.host) return 'PAPILOT_ACCOUNT_ORIGIN must differ from PAPILOT_PUBLIC_ORIGIN, the tailnet address; use another port, such as :10000.';
28
+ return null;
29
+ }
30
+ // HSTS on the public address covers every port of its host name, so it is
31
+ // left off when the tailnet endpoint on that host uses plain HTTP.
32
+ export function accountHsts(accountOrigin: string, publicOrigin: string) {
33
+ const account = new URL(accountOrigin);
34
+ return account.protocol === 'https:' && !(publicOrigin.startsWith('http:') && new URL(publicOrigin).hostname === account.hostname);
35
+ }
@@ -17,6 +17,11 @@ export class Store extends EventEmitter {
17
17
  CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, task_id TEXT NOT NULL, created INTEGER NOT NULL, data TEXT NOT NULL);
18
18
  CREATE TABLE IF NOT EXISTS deleted_tasks(id TEXT PRIMARY KEY, deleted INTEGER NOT NULL);
19
19
  CREATE TABLE IF NOT EXISTS annotations(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, data TEXT NOT NULL);
20
+ CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, data TEXT NOT NULL);
21
+ CREATE TABLE IF NOT EXISTS credentials(id TEXT PRIMARY KEY, user_id TEXT NOT NULL, data TEXT NOT NULL);
22
+ CREATE TABLE IF NOT EXISTS invites(token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
23
+ CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY, id TEXT UNIQUE NOT NULL, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
24
+ CREATE TABLE IF NOT EXISTS members(project_id TEXT NOT NULL, user_id TEXT NOT NULL, PRIMARY KEY(project_id, user_id));
20
25
  CREATE INDEX IF NOT EXISTS task_items ON items(task_id,created);
21
26
  CREATE INDEX IF NOT EXISTS task_runs ON runs(task_id,status);`);
22
27
  }
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import fs from 'node:fs';
@@ -13,7 +13,7 @@ import { findSkill, type SkillFile } from './skills.ts';
13
13
  import { buildFixPrompt, reviewInstructions, skillPrompt, summarizeFeedback } from '../conversation.ts';
14
14
  import { commitMessageFrom } from '../commit.ts';
15
15
  import { commitChanges, conflictMarkers, gitCommit, gitRemote, gitStatus, mergeConflicts, mergeInProgress } from './manuscript.ts';
16
- import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure } from '../shared.ts';
16
+ import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure, Author } from '../shared.ts';
17
17
 
18
18
  const execute = promisify(execFile);
19
19
  const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@@ -43,6 +43,10 @@ const FIXABLE = /^(Some edits are outside the task scope|The revised document co
43
43
  const BUILD_FIX = 'build-fix-';
44
44
 
45
45
  const AGENTS: AgentId[] = ['codex', 'claude', 'antigravity'];
46
+ // The small model an agent offers for short text such as a commit message,
47
+ // by the names agents give them (GPT Luna and mini, Claude Haiku, Gemini Flash).
48
+ const LIGHT = /\b(?:luna|mini|nano|haiku|flash|lite)\b/i;
49
+ export const lightModel = (models: AgentModel[]) => models.find(model => LIGHT.test(model.id) || LIGHT.test(model.name));
46
50
  // A commit message's agent sees the changes in its prompt and needs no tools.
47
51
  const commitInstructions = 'You write Git commit messages for the repository of a LaTeX paper. Reply with the commit message only, without a preamble, quotes or a code fence: a subject line in the imperative mood under 72 characters and, only when the change needs more explanation, a blank line and a body of a few short plain sentences. Describe what changed in the paper, such as its wording, sections, equations, figures or references, rather than listing files. Follow the style and language of the recent commit subjects, or write in English when there are none. Everything you need is in the message: do not run commands, read files or edit anything.';
48
52
  // A merge's agent edits the conflicted files in the manuscript checkout and nothing else.
@@ -72,6 +76,9 @@ export class Workspace extends EventEmitter {
72
76
  private flushTimer: NodeJS.Timeout | null = null;
73
77
  private connecting: Promise<void> | null = null;
74
78
  private usageTimers = new Map<AgentId, NodeJS.Timeout>();
79
+ // Commit messages by agent, model and prompt, kept or still being written,
80
+ // so one written ahead for a finished review is there when the dialog opens.
81
+ private messages = new Map<string, Promise<{ message: string; agent: AgentId }>>();
75
82
  constructor(public store: Store, public codex: AgentRuntime = new Codex(), private concurrency = 2, private connectReview = reviewClient, others: Partial<Record<AgentId, AgentRuntime>> = {}) {
76
83
  super();
77
84
  this.agents = { codex, claude: others.claude || new ClaudeAgent(), antigravity: others.antigravity || new AntigravityAgent() };
@@ -137,10 +144,12 @@ export class Workspace extends EventEmitter {
137
144
  this.store.saveProject(project); return project;
138
145
  }
139
146
  // A task can start from a skill; its instructions are then the author's own words, possibly none.
140
- async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; agent?: AgentId; model?: string; effort?: string; id: string }) {
147
+ async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; agent?: AgentId; model?: string; effort?: string; id: string; author?: Author }) {
141
148
  if (!/^[\w-]{16,64}$/.test(input.id)) throw new Error('Invalid task ID.');
142
149
  if (this.store.taskDeleted(input.id)) throw new Error('This task was deleted. Create a new task instead.');
143
- const existing = this.store.tasks().find(t => t.id === input.id); if (existing) return existing;
150
+ // A retried request returns its task; an ID from another paper is refused.
151
+ const existing = this.store.tasks().find(t => t.id === input.id);
152
+ if (existing) { if (existing.projectId !== input.projectId) throw new Error('Invalid task ID.'); return existing; }
144
153
  if (this.preparing.has(input.id)) throw new Error('This task is being created. Refresh in a moment.');
145
154
  this.preparing.add(input.id);
146
155
  try {
@@ -153,14 +162,14 @@ export class Workspace extends EventEmitter {
153
162
  worktree: info.worktree, threadId: null, turnId: null, agent: input.agent || 'codex', model: input.model || null, effort: input.effort || null, status: 'idle',
154
163
  createdAt: Date.now(), updatedAt: Date.now(), feedbackCursor: 0, error: null, review: null };
155
164
  this.store.saveTask(task);
156
- await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false, { skill });
165
+ await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false, { skill, author: input.author });
157
166
  return this.store.task(task.id);
158
167
  } finally { this.preparing.delete(input.id); }
159
168
  }
160
169
  // The author's message, a skill run with or without one, the review
161
170
  // feedback alone, or a build the agent broke. The chat keeps what happened;
162
171
  // the agent gets the prompt.
163
- async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean; build?: BuildFailure } = {}) {
172
+ async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean; build?: BuildFailure; author?: Author } = {}) {
164
173
  const task = this.store.task(taskId);
165
174
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
166
175
  const existing = this.store.runByKey(requestKey);
@@ -179,7 +188,7 @@ export class Workspace extends EventEmitter {
179
188
  if (task.error) prompt += '\n\nLatest task/review error:\n' + task.error + '\nThe latest accepted manuscript is at ' + this.store.project(task.projectId).path + '. You may read it to understand accepted changes, but must only edit your isolated task worktree. Prepare a revised proposal for another review; never write to or accept changes in the manuscript checkout.';
180
189
  }
181
190
  const run: Run = { id: randomUUID(), taskId, requestKey, prompt, feedbackCursor: cursor, status: 'queued', createdAt: Date.now(), finishedAt: null };
182
- const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}) };
191
+ const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}), ...(options.author ? { author: options.author } : {}) };
183
192
  const saved = this.store.transaction(() => {
184
193
  // Feedback loading yields to other requests. Recheck identity, queue, and
185
194
  // task status together before committing; none of the earlier reads own a lock.
@@ -535,13 +544,13 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
535
544
  }
536
545
  // The agent that writes a commit message or resolves a merge: the one the
537
546
  // author chose, or else the first that is signed in, with the chosen model
538
- // or the default one.
539
- private helper(choice: { agent?: AgentId; model?: string }, task: string) {
547
+ // or the default one; light asks for the agent's small model when it has one.
548
+ private helper(choice: { agent?: AgentId; model?: string }, task: string, light = false) {
540
549
  const usable = (id: AgentId) => this.agents[id].ready && this.runtimes[id].authenticated;
541
550
  const id = choice.agent && usable(choice.agent) ? choice.agent : AGENTS.find(usable);
542
551
  if (!id) throw new Error(`Sign in to Codex, Claude or Antigravity to ${task}.`);
543
552
  const models = this.runtimes[id].models;
544
- return { id, model: models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
553
+ return { id, model: (light ? lightModel(models) : undefined) || models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
545
554
  }
546
555
  // One turn on a thread the agent does not keep, outside any task: its
547
556
  // reply is returned and no chat shows it.
@@ -568,9 +577,11 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
568
577
  });
569
578
  }
570
579
  // Writes a commit message for the chosen manuscript files, like Cursor's
571
- // generate button: one read-only turn with the lightest reasoning the model offers.
572
- async commitMessage(projectId: string, paths: string[], choice: { agent?: AgentId; model?: string } = {}) {
573
- const project = this.store.project(projectId), { id, model } = this.helper(choice, 'write a commit message');
580
+ // generate button: one read-only turn on the agent's small model, with the
581
+ // lightest reasoning it offers. The same changes get the message already
582
+ // written, or being written, for them unless the author asks for a fresh one.
583
+ async commitMessage(projectId: string, paths: string[], choice: { agent?: AgentId; fresh?: boolean } = {}) {
584
+ const project = this.store.project(projectId), { id, model } = this.helper(choice, 'write a commit message', true);
574
585
  const { files, patch, recent } = await commitChanges(project.path, paths);
575
586
  const tasks = this.store.tasks().filter(task => task.projectId === projectId && task.review?.files?.some(file => paths.includes(file))).map(task => task.title);
576
587
  const prompt = [
@@ -579,10 +590,29 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
579
590
  `Changed files:\n${files.map(file => `${file.status === '?' ? 'A' : file.status} ${file.from ? `${file.from} -> ` : ''}${file.path}`).join('\n')}`,
580
591
  `Diff:\n${patch}`,
581
592
  ].filter(Boolean).join('\n\n');
582
- const reply = await this.oneOff(id, { cwd: project.path, sandbox: 'read-only', instructions: commitInstructions, prompt, model, effort: model?.efforts[0], timeout: 120000, what: 'write a commit message' });
583
- const message = commitMessageFrom(reply);
584
- if (!message) throw new Error(`${AGENT_NAMES[id]} did not return a commit message. Try again.`);
585
- return { message, agent: id };
593
+ const key = createHash('sha256').update(`${id}\0${model?.id ?? ''}\0${prompt}`).digest('hex'), kept = this.messages.get(key);
594
+ if (kept && !choice.fresh) return kept;
595
+ const written = this.oneOff(id, { cwd: project.path, sandbox: 'read-only', instructions: commitInstructions, prompt, model, effort: model?.efforts[0], timeout: 120000, what: 'write a commit message' }).then(reply => {
596
+ const message = commitMessageFrom(reply);
597
+ if (!message) throw new Error(`${AGENT_NAMES[id]} did not return a commit message. Try again.`);
598
+ return { message, agent: id };
599
+ });
600
+ // Only the latest few messages are kept, and never a failed one.
601
+ this.messages.delete(key); this.messages.set(key, written);
602
+ for (const old of this.messages.keys()) { if (this.messages.size <= 20) break; this.messages.delete(old); }
603
+ written.catch(() => { if (this.messages.get(key) === written) this.messages.delete(key); });
604
+ return written;
605
+ }
606
+ // A finished review offers to commit what it accepted, so after the
607
+ // author's last decision its commit message is written ahead.
608
+ async prepareCommitMessage(taskId: string) {
609
+ try {
610
+ const task = this.store.task(taskId), files = task.review?.files || [];
611
+ if (task.status !== 'reviewing' || task.review?.counts?.pending || !files.length) return;
612
+ const project = this.store.project(task.projectId), git = await gitStatus(project.path);
613
+ const paths = git.merging ? [] : files.filter(file => git.files.some(item => item.path === file));
614
+ if (paths.length) await this.commitMessage(project.id, paths, { agent: this.agentOf(task) });
615
+ } catch { /* The commit dialog writes the message when it opens. */ }
586
616
  }
587
617
  // Resolves a merge's conflicts in the manuscript checkout itself: the agent
588
618
  // edits only the conflicted files, at the model's default reasoning, and
package/src/shared.ts CHANGED
@@ -20,7 +20,9 @@ export interface TimelineItem { id: string; taskId: string; type: string; text:
20
20
  // A user message can run a skill or carry review feedback; the chat shows
21
21
  // those as what they are rather than as the prompt the agent receives.
22
22
  // Papilot itself sends the agent a build its own edits broke.
23
- export interface MessageMeta { skill?: { id: string; name: string }; feedback?: FeedbackSummary; sendsFeedback?: boolean; build?: BuildFailure }
23
+ export interface MessageMeta { skill?: { id: string; name: string }; feedback?: FeedbackSummary; sendsFeedback?: boolean; build?: BuildFailure; author?: Author }
24
+ // Who wrote a message or note; none means the owner.
25
+ export interface Author { id: string; name: string }
24
26
  // A failed review build as the engine reports it; log is the compiler log's path.
25
27
  export interface BuildFailure { error: string; details: string[]; log?: string }
26
28
  export interface FeedbackSummary { round: number | null; accepted: number; rejected: number; undone: number; comments: { where: string; text: string }[]; buildError?: boolean }
@@ -32,11 +34,21 @@ export interface AgentModel { id: string; name: string; isDefault: boolean; effo
32
34
  export interface UsageWindow { usedPercent: number; minutes: number | null; resetsAt: number | null }
33
35
  export interface Usage { plan: string | null; windows: UsageWindow[]; credits: { unlimited: boolean; balance: number | null } | null; reached: boolean }
34
36
  export interface RuntimeStatus { connected: boolean; authenticated: boolean; account: string | null; error: string | null; models: AgentModel[]; usage?: Usage | null }
35
- export interface Snapshot { projects: Project[]; tasks: Task[]; requests: PendingRequest[]; runtime: RuntimeStatus; runtimes?: Partial<Record<AgentId, RuntimeStatus>>; version: string }
37
+ export interface Snapshot { projects: Project[]; tasks: Task[]; requests: PendingRequest[]; runtime: RuntimeStatus; runtimes?: Partial<Record<AgentId, RuntimeStatus>>; version: string; viewer?: Viewer }
38
+ // Who is looking: the owner on this host or Tailscale (local), or a person
39
+ // signed in with a passkey at the public URL (account). Older servers send none.
40
+ // owner names the owner, whose messages carry no author.
41
+ export interface Viewer { id: string; name: string; role: 'owner' | 'member'; listener: 'local' | 'account'; owner: string }
42
+ // A person the owner invited, as the People dialog shows them. joined means
43
+ // they have registered at least one passkey; projects are the shared papers.
44
+ export interface Person { id: string; name: string; role: 'owner' | 'member'; joined: boolean; projects: string[]; lastSeenAt: number | null; devices: Device[] }
45
+ // A passkey or a signed-in browser session, either of which the owner can revoke.
46
+ export interface Device { id: string; kind: 'passkey' | 'session'; label: string; createdAt: number; lastUsedAt: number | null }
47
+ export interface People { origin: string | null; people: Person[] }
36
48
  export interface GitFile { path: string; status: string; staged: boolean; lines?: [number, number]; from?: string }
37
49
  // merging is a merge in progress, with the files whose conflict markers remain.
38
50
  export interface GitStatus { branch: string | null; upstream: string | null; ahead: number; behind: number; files: GitFile[]; head: { hash: string; subject: string; date: number } | null; remote?: boolean; merging?: { message: string; conflicts: string[] } | null }
39
51
  export interface GitDiff { path: string; diff: string; truncated: boolean }
40
52
  export interface ManuscriptBuild { status: 'building' | 'ready' | 'failed'; key?: string; pages?: [number, number][]; builtAt?: number; error?: string; current: boolean }
41
- export interface Annotation { id: string; projectId: string; key: string; page: number; rect: [number, number, number, number]; rects?: [number, number, number, number][]; comment: string; quote: string; source: { file: string; lines: [number, number]; excerpt?: string } | null; createdAt: number; taskId?: string }
53
+ export interface Annotation { id: string; projectId: string; key: string; page: number; rect: [number, number, number, number]; rects?: [number, number, number, number][]; comment: string; quote: string; source: { file: string; lines: [number, number]; excerpt?: string } | null; createdAt: number; taskId?: string; author?: Author }
42
54
  export interface Manuscript { git: GitStatus; build: ManuscriptBuild; annotations: Annotation[] }
@@ -54,6 +54,40 @@ export interface Raster {
54
54
  stride: number;
55
55
  pixels: Uint8Array;
56
56
  }
57
+ // mupdf.js's walk turns each character into a string with String.fromCharCode,
58
+ // which keeps only the low 16 bits: math italic 𝐹 (U+1D439) arrives as the
59
+ // Hangul syllable 퐹 (U+D439). The JSON export spells every line correctly, one
60
+ // code point per character, so a character comes from there whenever the two
61
+ // agree on its low 16 bits.
62
+ export function walkText(
63
+ text: mupdf.StructuredText,
64
+ walker: Parameters<mupdf.StructuredText["walk"]>[0],
65
+ ) {
66
+ const lines = (
67
+ JSON.parse(text.asJSON()) as { blocks: { lines?: { text: string }[] }[] }
68
+ ).blocks.flatMap((b) => (b.lines ?? []).map((l) => Array.from(l.text)));
69
+ let next = 0,
70
+ line: string[] = [],
71
+ index = 0;
72
+ text.walk({
73
+ ...walker,
74
+ beginLine: (...args) => {
75
+ line = lines[next++] ?? [];
76
+ index = 0;
77
+ walker.beginLine?.(...args);
78
+ },
79
+ onChar: (c, ...args) => {
80
+ const full = line[index++];
81
+ walker.onChar?.(
82
+ full !== undefined &&
83
+ (full.codePointAt(0)! & 0xffff) === c.charCodeAt(0)
84
+ ? full
85
+ : c,
86
+ ...args,
87
+ );
88
+ },
89
+ });
90
+ }
57
91
  export class PdfPage {
58
92
  private columns?: boolean;
59
93
  raw: mupdf.Page;
@@ -73,7 +107,7 @@ export class PdfPage {
73
107
  "preserve-whitespace,preserve-ligatures",
74
108
  );
75
109
  let block: TextBlock, line: Line;
76
- text.walk({
110
+ walkText(text, {
77
111
  beginTextBlock: (rect) => {
78
112
  block = { rect, text: "", lines: [] };
79
113
  this.blocks.push(block);