@bli-cockpit/cli 0.2.270 → 0.2.272
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.
- package/dist/adapters/raw-evidence-sanitize-held.js +1 -1
- package/dist/adapters/raw-evidence-sanitize-lines.js +257 -0
- package/dist/adapters/raw-evidence-sanitize-staged.js +97 -0
- package/dist/adapters/raw-evidence-transcript-file.js +8 -8
- package/dist/adapters/raw-evidence-transcript-stream.js +3 -2
- package/dist/adapters/staged-lines.js +67 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/image-census-stream.js +7 -1
- package/dist/image-description-session.js +28 -6
- package/dist/pdf-byte-scan.js +109 -0
- package/dist/pdf-conversion-cache.js +91 -0
- package/dist/pdf-convert.js +152 -0
- package/dist/pdf-layout.js +287 -0
- package/dist/pdf-page-image.js +114 -0
- package/dist/pdf-transcript-step.js +218 -0
- package/dist/slim/slim-stream.js +3 -2
- package/package.json +5 -4
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One decoded PDF page image to a small grayscale PNG the image describe door takes (BLI-23347).
|
|
3
|
+
*
|
|
4
|
+
* A scanned PDF page is one big raster (a 7.4 MB scan decodes to 2550x3300 RGB, 25 MB). The describer
|
|
5
|
+
* takes at most 3 MB and reads text fine at about 1,600 px on the long side, so the raster is box-filtered
|
|
6
|
+
* down, turned to 8-bit grey and PNG-encoded, trying smaller sizes until it fits. Pure: pixels in, bytes
|
|
7
|
+
* out, no pdf library and no native dependency, so it runs the same on macOS and Windows.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { deflateSync } from "node:zlib";
|
|
11
|
+
/** Long-side sizes tried in order; the first PNG under the byte limit wins. */
|
|
12
|
+
const LONG_SIDES = [1600, 1200, 900, 640];
|
|
13
|
+
const CRC_TABLE = (() => {
|
|
14
|
+
const table = new Uint32Array(256);
|
|
15
|
+
for (let n = 0; n < 256; n += 1) {
|
|
16
|
+
let c = n;
|
|
17
|
+
for (let k = 0; k < 8; k += 1)
|
|
18
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
19
|
+
table[n] = c >>> 0;
|
|
20
|
+
}
|
|
21
|
+
return table;
|
|
22
|
+
})();
|
|
23
|
+
function crc32(buffer) {
|
|
24
|
+
let c = 0xffffffff;
|
|
25
|
+
for (let i = 0; i < buffer.length; i += 1)
|
|
26
|
+
c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8);
|
|
27
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
28
|
+
}
|
|
29
|
+
function chunk(type, body) {
|
|
30
|
+
const head = Buffer.alloc(8);
|
|
31
|
+
head.writeUInt32BE(body.length, 0);
|
|
32
|
+
head.write(type, 4, "latin1");
|
|
33
|
+
const crc = Buffer.alloc(4);
|
|
34
|
+
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
|
|
35
|
+
return Buffer.concat([head, body, crc]);
|
|
36
|
+
}
|
|
37
|
+
/** Grey level (0-255) of every pixel, one byte each. */
|
|
38
|
+
function toGrey(raster) {
|
|
39
|
+
const { width, height, kind, data } = raster;
|
|
40
|
+
const grey = new Uint8Array(width * height);
|
|
41
|
+
if (kind === 1) {
|
|
42
|
+
const stride = Math.ceil(width / 8);
|
|
43
|
+
for (let y = 0; y < height; y += 1) {
|
|
44
|
+
for (let x = 0; x < width; x += 1)
|
|
45
|
+
grey[y * width + x] = (data[y * stride + (x >> 3)] >> (7 - (x & 7))) & 1 ? 255 : 0;
|
|
46
|
+
}
|
|
47
|
+
return grey;
|
|
48
|
+
}
|
|
49
|
+
const step = kind === 2 ? 3 : 4;
|
|
50
|
+
for (let i = 0, p = 0; i < grey.length; i += 1, p += step) {
|
|
51
|
+
const luma = (data[p] * 299 + data[p + 1] * 587 + data[p + 2] * 114) / 1000;
|
|
52
|
+
// Transparent pixels read as paper, not as black.
|
|
53
|
+
const alpha = kind === 3 ? data[p + 3] / 255 : 1;
|
|
54
|
+
grey[i] = Math.round(luma * alpha + 255 * (1 - alpha));
|
|
55
|
+
}
|
|
56
|
+
return grey;
|
|
57
|
+
}
|
|
58
|
+
/** Box-filter `grey` (width x height) down to `toWidth` x `toHeight`. */
|
|
59
|
+
function shrink(grey, width, height, toWidth, toHeight) {
|
|
60
|
+
if (toWidth === width && toHeight === height)
|
|
61
|
+
return grey;
|
|
62
|
+
const out = new Uint8Array(toWidth * toHeight);
|
|
63
|
+
for (let y = 0; y < toHeight; y += 1) {
|
|
64
|
+
const y0 = Math.floor((y * height) / toHeight);
|
|
65
|
+
const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * height) / toHeight));
|
|
66
|
+
for (let x = 0; x < toWidth; x += 1) {
|
|
67
|
+
const x0 = Math.floor((x * width) / toWidth);
|
|
68
|
+
const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * width) / toWidth));
|
|
69
|
+
let sum = 0;
|
|
70
|
+
for (let yy = y0; yy < y1; yy += 1)
|
|
71
|
+
for (let xx = x0; xx < x1; xx += 1)
|
|
72
|
+
sum += grey[yy * width + xx];
|
|
73
|
+
out[y * toWidth + x] = Math.round(sum / ((y1 - y0) * (x1 - x0)));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** An 8-bit grey PNG with the Sub filter on every row. */
|
|
79
|
+
export function encodeGreyPng(grey, width, height) {
|
|
80
|
+
const rows = Buffer.alloc((width + 1) * height);
|
|
81
|
+
for (let y = 0; y < height; y += 1) {
|
|
82
|
+
const at = y * (width + 1);
|
|
83
|
+
rows[at] = 1;
|
|
84
|
+
for (let x = 0; x < width; x += 1)
|
|
85
|
+
rows[at + 1 + x] = (grey[y * width + x] - (x > 0 ? grey[y * width + x - 1] : 0)) & 0xff;
|
|
86
|
+
}
|
|
87
|
+
const header = Buffer.alloc(13);
|
|
88
|
+
header.writeUInt32BE(width, 0);
|
|
89
|
+
header.writeUInt32BE(height, 4);
|
|
90
|
+
header[8] = 8; // bit depth
|
|
91
|
+
header[9] = 0; // grey
|
|
92
|
+
return Buffer.concat([
|
|
93
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
94
|
+
chunk("IHDR", header),
|
|
95
|
+
chunk("IDAT", deflateSync(rows, { level: 6 })),
|
|
96
|
+
chunk("IEND", Buffer.alloc(0)),
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
/** The raster as a PNG of at most `maxBytes`, or null when even the smallest size is too big. */
|
|
100
|
+
export function rasterToPagePng(raster, maxBytes) {
|
|
101
|
+
const { width, height } = raster;
|
|
102
|
+
if (!(width > 0 && height > 0) || raster.data.length < (raster.kind === 1 ? Math.ceil(width / 8) * height : width * height * (raster.kind === 2 ? 3 : 4)))
|
|
103
|
+
return null;
|
|
104
|
+
const grey = toGrey(raster);
|
|
105
|
+
for (const longSide of LONG_SIDES) {
|
|
106
|
+
const scale = Math.min(1, longSide / Math.max(width, height));
|
|
107
|
+
const toWidth = Math.max(1, Math.round(width * scale));
|
|
108
|
+
const toHeight = Math.max(1, Math.round(height * scale));
|
|
109
|
+
const bytes = encodeGreyPng(shrink(grey, width, height, toWidth, toHeight), toWidth, toHeight);
|
|
110
|
+
if (bytes.length <= maxBytes)
|
|
111
|
+
return { bytes, sha256: createHash("sha256").update(bytes).digest("hex"), width: toWidth, height: toHeight };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PDF step of the transcript prepare path (BLI-23347): turn each base64 PDF into a marker block, in the
|
|
3
|
+
* same pass and under the same budgets as images. Drop mode only.
|
|
4
|
+
*
|
|
5
|
+
* The session (`image-description-session.ts`) holds the image state; this module plugs into it through ONE
|
|
6
|
+
* seam, `addPdfsToFinds`, which takes the images the session found and returns the same shape with the PDFs
|
|
7
|
+
* added:
|
|
8
|
+
*
|
|
9
|
+
* - every PDF occurrence is a stand-in `EmbeddedImage` carrying `replacement` (its marker text, set once the
|
|
10
|
+
* conversion is complete), so the swap in `replaceImagesWithMarkers` and `slimLineStream` needs no PDF code;
|
|
11
|
+
* - a scanned PDF's page images join `unique`, so the existing describe door, budgets, backoff and state do
|
|
12
|
+
* the describing; the PDF is replaced only when every page image has a description (an undescribed PDF is
|
|
13
|
+
* kept, exactly as an undescribed image is);
|
|
14
|
+
* - the FIRST copy of a PDF in the transcript gets the full marker block, every later copy a one-line reference.
|
|
15
|
+
*
|
|
16
|
+
* Conversion results are cached on disk by sha256 (`pdf-conversion-cache.ts`), so a transcript rebuilt every
|
|
17
|
+
* tick converts each PDF once. A PDF that cannot be converted (too big, the budget spent, unreadable) is left
|
|
18
|
+
* as it is and says why in the log; it is not counted, so it never keeps a transcript "pending" forever.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { formatImageMarker, formatPdfMarker, formatPdfReference } from "@bli-cockpit/telemetry-core";
|
|
22
|
+
import { convertPdf } from "./pdf-convert.js";
|
|
23
|
+
import { readCachedPdf, writeCachedPdf } from "./pdf-conversion-cache.js";
|
|
24
|
+
import { bytesMayHoldPdf, scanPdfsInLine } from "./pdf-byte-scan.js";
|
|
25
|
+
const TAG = "[pdf-step]";
|
|
26
|
+
/** Largest PDF decoded for conversion: the document, its pdf.js structures and one page raster must fit comfortably. */
|
|
27
|
+
export const MAX_PDF_BYTES = 25 * 1024 * 1024;
|
|
28
|
+
/** Time spent converting across one sync; later PDFs wait for the next tick (the cache keeps finished ones). */
|
|
29
|
+
export const MAX_PDF_RUN_MS = 60_000;
|
|
30
|
+
const BLOCK_CHARS = 48 * 1024;
|
|
31
|
+
const runStates = new WeakMap();
|
|
32
|
+
function runStateOf(key) {
|
|
33
|
+
let state = runStates.get(key);
|
|
34
|
+
if (!state) {
|
|
35
|
+
state = { spentMs: 0, records: new Map() };
|
|
36
|
+
runStates.set(key, state);
|
|
37
|
+
}
|
|
38
|
+
return state;
|
|
39
|
+
}
|
|
40
|
+
/** `text` in a JSON string that sits `depth` strings deep: one escaping pass per level, plus the string's own. */
|
|
41
|
+
function jsonEscaped(text, depth) {
|
|
42
|
+
let out = JSON.stringify(text).slice(1, -1);
|
|
43
|
+
if (depth === 1)
|
|
44
|
+
out = JSON.stringify(out).slice(1, -1);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** The decoded bytes of a base64 run, in blocks, into one exact-size buffer. */
|
|
48
|
+
function decode(payload, byteSize) {
|
|
49
|
+
const out = Buffer.alloc(byteSize);
|
|
50
|
+
let at = 0;
|
|
51
|
+
const length = payload.length;
|
|
52
|
+
for (let from = 0; from < length; from += BLOCK_CHARS) {
|
|
53
|
+
const block = Buffer.from(typeof payload === "string" ? payload.slice(from, from + BLOCK_CHARS) : payload.toString("latin1", from, Math.min(length, from + BLOCK_CHARS)), "base64");
|
|
54
|
+
if (at + block.length > byteSize)
|
|
55
|
+
return null;
|
|
56
|
+
block.copy(out, at);
|
|
57
|
+
at += block.length;
|
|
58
|
+
}
|
|
59
|
+
return at === byteSize ? new Uint8Array(out.buffer, out.byteOffset, out.length) : null;
|
|
60
|
+
}
|
|
61
|
+
const prefix = (sha256) => sha256.slice(0, 16);
|
|
62
|
+
export async function planPdfs(found, ctx) {
|
|
63
|
+
const state = runStateOf(ctx.key);
|
|
64
|
+
const distinct = new Map();
|
|
65
|
+
const order = new Map();
|
|
66
|
+
for (const pdf of found)
|
|
67
|
+
order.set(pdf.sha256, [...(order.get(pdf.sha256) ?? []), pdf]);
|
|
68
|
+
const pageImages = new Map();
|
|
69
|
+
const payloads = new Map();
|
|
70
|
+
for (const [sha256, copies] of order) {
|
|
71
|
+
const first = copies[0];
|
|
72
|
+
const started = Date.now();
|
|
73
|
+
// A PDF given up on earlier in this sync is not tried again.
|
|
74
|
+
if (state.records.has(sha256) && state.records.get(sha256) === null)
|
|
75
|
+
continue;
|
|
76
|
+
let entry = state.records.get(sha256) ?? (await readCachedPdf(ctx.stateDir, sha256));
|
|
77
|
+
const cacheHit = entry !== null;
|
|
78
|
+
const needsPages = entry?.kind === "scanned" && entry.pageImages.some((page) => !ctx.isDescribed(page.sha256));
|
|
79
|
+
if (!entry || needsPages) {
|
|
80
|
+
const skip = first.byteSize > MAX_PDF_BYTES ? "pdf_too_large" : state.spentMs > MAX_PDF_RUN_MS ? "pdf_time_budget_spent" : null;
|
|
81
|
+
if (skip) {
|
|
82
|
+
console.error(TAG + " PDF left as it is", JSON.stringify({ reason: skip, sha256_prefix: prefix(sha256), byte_size: first.byteSize, copies: copies.length }));
|
|
83
|
+
state.records.set(sha256, null);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const bytes = decode(await ctx.readBase64(first), first.byteSize);
|
|
87
|
+
if (!bytes || createHash("sha256").update(bytes).digest("hex") !== sha256)
|
|
88
|
+
throw ctx.sourceChanged();
|
|
89
|
+
const converted = await convertPdf(bytes);
|
|
90
|
+
state.spentMs += Date.now() - started;
|
|
91
|
+
if (converted.kind === "failed") {
|
|
92
|
+
console.error(TAG + " PDF left as it is", JSON.stringify({ reason: converted.reason, sha256_prefix: prefix(sha256), byte_size: first.byteSize, pages: converted.pages, copies: copies.length }));
|
|
93
|
+
state.records.set(sha256, null);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (converted.kind === "text") {
|
|
97
|
+
entry = { kind: "text", pages: converted.pages, notes: converted.notes, body: converted.body };
|
|
98
|
+
}
|
|
99
|
+
else if (converted.pageImages.length === 0) {
|
|
100
|
+
console.error(TAG + " PDF left as it is", JSON.stringify({ reason: "scanned_without_page_images", sha256_prefix: prefix(sha256), byte_size: first.byteSize, pages: converted.pages, copies: copies.length }));
|
|
101
|
+
state.records.set(sha256, null);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
entry = { kind: "scanned", pages: converted.pages, notes: converted.notes, pageImages: converted.pageImages.map(({ page, png }) => ({ page, sha256: png.sha256, byteSize: png.bytes.length })) };
|
|
106
|
+
for (const { png } of converted.pageImages) {
|
|
107
|
+
if (ctx.isDescribed(png.sha256) || payloads.has(png.sha256))
|
|
108
|
+
continue;
|
|
109
|
+
payloads.set(png.sha256, png.bytes.toString("base64"));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
await writeCachedPdf(ctx.stateDir, sha256, entry);
|
|
113
|
+
}
|
|
114
|
+
if (!entry)
|
|
115
|
+
continue;
|
|
116
|
+
state.records.set(sha256, entry);
|
|
117
|
+
if (entry.kind === "scanned") {
|
|
118
|
+
for (const page of entry.pageImages) {
|
|
119
|
+
if (ctx.isDescribed(page.sha256) || !payloads.has(page.sha256))
|
|
120
|
+
continue;
|
|
121
|
+
pageImages.set(page.sha256, { replaceStart: -1, replaceEnd: -1, base64Start: -1, base64End: -1, sha256: page.sha256, mediaType: "image/png", byteSize: page.byteSize });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
console.error(TAG + " PDF converted", JSON.stringify({ kind: entry.kind, sha256_prefix: prefix(sha256), byte_size: first.byteSize, pages: entry.pages, chars: entry.kind === "text" ? entry.body.length : null, page_images: entry.kind === "scanned" ? entry.pageImages.length : null, copies: copies.length, cache: cacheHit ? "hit" : "miss", elapsed_ms: Date.now() - started }));
|
|
125
|
+
distinct.set(sha256, { pdf: first, copies, entry });
|
|
126
|
+
}
|
|
127
|
+
const occurrences = [];
|
|
128
|
+
const standIns = new Map();
|
|
129
|
+
let counted = 0;
|
|
130
|
+
for (const pdf of found) {
|
|
131
|
+
if (!distinct.has(pdf.sha256))
|
|
132
|
+
continue;
|
|
133
|
+
const standIn = { replaceStart: pdf.replaceStart, replaceEnd: pdf.replaceEnd, base64Start: pdf.base64Start, base64End: pdf.base64End, sha256: pdf.sha256, mediaType: "image/png", byteSize: pdf.byteSize };
|
|
134
|
+
standIns.set(pdf, standIn);
|
|
135
|
+
occurrences.push(standIn);
|
|
136
|
+
counted += 1;
|
|
137
|
+
}
|
|
138
|
+
const settle = () => {
|
|
139
|
+
let replacedCount = 0;
|
|
140
|
+
let charsSaved = 0;
|
|
141
|
+
for (const { copies, entry } of distinct.values()) {
|
|
142
|
+
const ready = entry.kind === "text" || entry.pageImages.every((page) => ctx.isDescribed(page.sha256));
|
|
143
|
+
if (!ready)
|
|
144
|
+
continue;
|
|
145
|
+
const facts = { sha256: copies[0].sha256, pages: entry.pages, byteSize: copies[0].byteSize };
|
|
146
|
+
const full = entry.kind === "text"
|
|
147
|
+
? formatPdfMarker({ ...facts, method: "text-layer", notes: entry.notes }, entry.body)
|
|
148
|
+
: formatPdfMarker({ ...facts, method: "page-images", notes: entry.notes }, entry.pageImages.map((page) => `page ${page.page}: ${formatImageMarker({ sha256: page.sha256, mediaType: "image/png", byteSize: page.byteSize })}`).join("\n"));
|
|
149
|
+
const reference = formatPdfReference(facts);
|
|
150
|
+
copies.forEach((pdf, index) => {
|
|
151
|
+
const standIn = standIns.get(pdf);
|
|
152
|
+
const text = index === 0 ? full : reference;
|
|
153
|
+
standIn.replacement = jsonEscaped(text, pdf.escapeDepth);
|
|
154
|
+
replacedCount += 1;
|
|
155
|
+
charsSaved += pdf.replaceEnd - pdf.replaceStart - standIn.replacement.length;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return { replacedCount, charsSaved };
|
|
159
|
+
};
|
|
160
|
+
return { occurrences, pageImages, payloads, counted, settle };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The session's finds with the PDFs added; `finds` itself when there are none. Call only in drop mode.
|
|
164
|
+
*/
|
|
165
|
+
export async function addPdfsToFinds(finds, found, ctx) {
|
|
166
|
+
if (found.length === 0)
|
|
167
|
+
return finds;
|
|
168
|
+
const step = await planPdfs(found, ctx);
|
|
169
|
+
if (step.counted === 0)
|
|
170
|
+
return finds;
|
|
171
|
+
const images = finds.images ? [...finds.images, ...step.occurrences].sort((a, b) => a.replaceStart - b.replaceStart) : undefined;
|
|
172
|
+
return {
|
|
173
|
+
...finds,
|
|
174
|
+
total: finds.total + step.counted,
|
|
175
|
+
unique: new Map([...finds.unique, ...step.pageImages]),
|
|
176
|
+
base64For: (image) => step.payloads.get(image.sha256) ?? finds.base64For(image),
|
|
177
|
+
swapped: (replace) => {
|
|
178
|
+
const base = finds.swapped(replace);
|
|
179
|
+
const pdf = step.settle();
|
|
180
|
+
return { replacedCount: base.replacedCount + pdf.replacedCount, charsSaved: base.charsSaved + pdf.charsSaved };
|
|
181
|
+
},
|
|
182
|
+
...(images ? { images } : {}),
|
|
183
|
+
extraOccurrences: step.occurrences,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** The PDFs of a whole transcript's text, with character offsets (the text path; files at the streaming size use the census). */
|
|
187
|
+
export function pdfsOfText(text) {
|
|
188
|
+
if (!text.includes("JVBERi0") && !text.includes("data:application/pdf"))
|
|
189
|
+
return [];
|
|
190
|
+
const bytes = Buffer.from(text, "utf8");
|
|
191
|
+
if (!bytesMayHoldPdf(bytes))
|
|
192
|
+
return [];
|
|
193
|
+
const byteOffsets = scanPdfsInLine(bytes);
|
|
194
|
+
const out = [];
|
|
195
|
+
let consumedBytes = 0;
|
|
196
|
+
let consumedChars = 0;
|
|
197
|
+
const charAt = (byteOffset) => {
|
|
198
|
+
consumedChars += bytes.toString("utf8", consumedBytes, byteOffset).length;
|
|
199
|
+
consumedBytes = byteOffset;
|
|
200
|
+
return consumedChars;
|
|
201
|
+
};
|
|
202
|
+
for (const pdf of byteOffsets) {
|
|
203
|
+
const replaceStart = charAt(pdf.replaceStart);
|
|
204
|
+
const base64Start = replaceStart + (pdf.base64Start - pdf.replaceStart);
|
|
205
|
+
const length = pdf.base64End - pdf.base64Start;
|
|
206
|
+
const replaceEnd = base64Start + length;
|
|
207
|
+
consumedBytes = pdf.replaceEnd;
|
|
208
|
+
consumedChars = replaceEnd;
|
|
209
|
+
out.push({ ...pdf, replaceStart, replaceEnd, base64Start, base64End: replaceEnd });
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
/** Merge two lists already sorted by `replaceStart`. */
|
|
214
|
+
export function mergeOccurrences(a, b) {
|
|
215
|
+
if (!b || b.length === 0)
|
|
216
|
+
return a;
|
|
217
|
+
return [...a, ...b].sort((x, y) => x.replaceStart - y.replaceStart);
|
|
218
|
+
}
|
package/dist/slim/slim-stream.js
CHANGED
|
@@ -84,7 +84,8 @@ export async function slimLineStream(lines, kind, occurrences, replace, sink) {
|
|
|
84
84
|
const image = occurrences[next];
|
|
85
85
|
if (image.replaceEnd > end)
|
|
86
86
|
throw new TranscriptNotStreamable("images_changed_between_passes");
|
|
87
|
-
|
|
87
|
+
// BLI-23347: a stand-in that carries its own replacement (a PDF) is always swapped.
|
|
88
|
+
if (image.replacement !== undefined || replace(image))
|
|
88
89
|
chosen.push(image);
|
|
89
90
|
}
|
|
90
91
|
let line;
|
|
@@ -95,7 +96,7 @@ export async function slimLineStream(lines, kind, occurrences, replace, sink) {
|
|
|
95
96
|
const pieces = [];
|
|
96
97
|
let cursor = 0;
|
|
97
98
|
for (const image of chosen) {
|
|
98
|
-
const marker = formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
|
|
99
|
+
const marker = image.replacement ?? formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
|
|
99
100
|
pieces.push(bytes.subarray(cursor, image.replaceStart - start), Buffer.from(marker, "utf8"));
|
|
100
101
|
cursor = image.replaceEnd - start;
|
|
101
102
|
replacedCount += 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.272",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,10 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.82",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.166",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.89",
|
|
33
|
+
"unpdf": "1.8.1"
|
|
33
34
|
},
|
|
34
35
|
"repository": {
|
|
35
36
|
"type": "git",
|