ConusAI vendor v0.1.0-dev.20261027+9b1131fb
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-dev.20261027+9b1131fb
2026-10-27 18:05:17 +00:00
commit 3102273ec7
538 changed files with 80203 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
// The export: one workbook of the lines a person approved. It reads `documents_line`, which only an
// approval writes (target.ts), so there is no filter here that a later change could forget: a line
// that is pending or rejected is not in the table this reads.
//
// The columns are fixed and in English on purpose. The file is an interchange format -- what a
// product's own import or a spreadsheet formula refers to by name -- and a header that changed with
// the reviewer's language would break both. A product that needs another layout (a customer's ERP
// import, say) adds its own exporter beside this one; that belongs to the customer's module.
import { conflict } from "@conusai-kit/core"
import { getFile } from "@conusai-kit/files"
import { documents, lines } from "../contract/index.js"
import type { DocumentScope, DocumentsDeps } from "./lines.js"
import { buildWorkbook, type Cell } from "./xlsx.js"
export const WORKBOOK_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
const PAGE = 200
export const EXPORT_HEADER = ["Label", "Value", "Page", "Source quote", "Edited by a reviewer"] as const
/** `report.final.pdf` becomes `report.final-approved.xlsx`; nothing a path or a header could misread. */
export function exportFilename(source: string): string {
const stem = source.replace(/\.[A-Za-z0-9]{1,5}$/u, "")
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is removed.
const safe = stem.replace(/[\u0000-\u001f\u007f"\\/:*?<>|]/gu, "_").trim()
return `${safe.length > 0 ? safe : "document"}-approved.xlsx`
}
export async function exportApproved(
deps: DocumentsDeps,
scope: DocumentScope,
fileId: string,
): Promise<{ filename: string; contentType: string; bytes: Uint8Array; lineCount: number }> {
const file = await getFile(deps.files, scope, fileId)
const document = (
await deps.data
.forScope(scope)
.of(documents)
.list({ where: [{ field: "fileId", op: "eq", value: file.fileId }], limit: 1 })
).items[0]
if (!document) throw conflict("this document has not been read yet")
const rows: Cell[][] = [[...EXPORT_HEADER]]
let cursor: string | undefined
do {
const page = await deps.data
.forScope(scope)
.of(lines)
.list({
where: [{ field: "documentId", op: "eq", value: document.id }],
order: [
{ field: "page", direction: "asc" },
{ field: "position", direction: "asc" },
],
limit: PAGE,
...(cursor === undefined ? {} : { cursor }),
})
for (const line of page.items) {
rows.push([line.label, line.value, line.page, line.quote, line.edited ? "yes" : "no"])
}
cursor = page.hasMore ? page.cursor : undefined
} while (cursor !== undefined)
// An empty workbook would read as "the document had nothing in it", which is not what was
// decided: nobody has approved anything yet.
if (rows.length === 1) throw conflict("no line of this document has been approved")
return {
filename: exportFilename(file.filename),
contentType: WORKBOOK_CONTENT_TYPE,
bytes: buildWorkbook("Approved lines", rows),
lineCount: rows.length - 1,
}
}
+123
View File
@@ -0,0 +1,123 @@
// The dependencies every use case of the module takes, and how a proposal becomes a line a reviewer
// sees. A line is not a row of this module until it is approved: until then it is a proposal of the
// proposals module, and this file is the one place the two are read together.
import type { TenantScope } from "@conusai-kit/core"
import type { DataSource, Filter } from "@conusai-kit/data"
import { type FilesDeps, sha256Hex } from "@conusai-kit/files"
import { type ProposalRow, type ProposalsDeps, type ProposalView, proposals, viewOf } from "@conusai-kit/proposals"
import { LINE_TARGET_KEY, type LineView, lineChangeSchema } from "../contract/index.js"
/** A project scope: the only level a document can live at. */
export type DocumentScope = TenantScope & { workspaceId: string; projectId: string }
export interface DocumentsDeps {
readonly data: DataSource
/** The files module's ports: a document is made from one of its files. */
readonly files: FilesDeps
/** The proposals module's ports: an extracted line is one of its proposals. */
readonly proposals: ProposalsDeps
}
/** The page size used to read this module's proposals. */
const PAGE = 200
/**
* Every proposal of this module's target matching the given record filter, any status, oldest
* first.
*
* The proposals module's own `listProposals` filters by status alone, so the filter by target is
* done here on its table, through the entity it exports: the `where` runs against the table's
* `(targetKey, targetRecordId)` index instead of paging the project's whole queue into memory and
* filtering it in JavaScript (one canonical copy of a line's state still — this adds a query, not
* a table). When the kit's list grows a `targetRecordId` filter of its own, this becomes a call
* of it again.
*/
async function proposalsWhere(
deps: DocumentsDeps,
scope: DocumentScope,
target: { recordId: string } | { recordIds: readonly string[] } | null,
): Promise<ProposalView[]> {
const byRecord: Filter<ProposalRow>[] =
target === null
? []
: "recordId" in target
? [{ field: "targetRecordId", op: "eq", value: target.recordId }]
: [{ field: "targetRecordId", op: "in", values: target.recordIds }]
const found: ProposalView[] = []
let cursor: string | undefined
do {
const page = await deps.proposals.data
.forScope(scope)
.of(proposals)
.list({
where: [{ field: "targetKey", op: "eq", value: LINE_TARGET_KEY }, ...byRecord],
order: [{ field: "createdAt", direction: "asc" }],
limit: PAGE,
...(cursor === undefined ? {} : { cursor }),
})
found.push(...page.items.map(viewOf))
cursor = page.hasMore ? page.cursor : undefined
} while (cursor !== undefined)
return found
}
/** Every proposal of this module's target in the project — or only one document's, when given. */
export function lineProposals(deps: DocumentsDeps, scope: DocumentScope, documentId?: string): Promise<ProposalView[]> {
return proposalsWhere(deps, scope, documentId === undefined ? null : { recordId: documentId })
}
/**
* This module's proposals of several documents at once, keyed by document id: the document list's
* join, one indexed page chain instead of one pass over the queue per document.
*/
export async function lineProposalsByDocument(
deps: DocumentsDeps,
scope: DocumentScope,
documentIds: readonly string[],
): Promise<Map<string, ProposalView[]>> {
const byDocument = new Map<string, ProposalView[]>()
if (documentIds.length === 0) return byDocument
for (const proposal of await proposalsWhere(deps, scope, { recordIds: documentIds })) {
if (proposal.targetRecordId === null) continue
const own = byDocument.get(proposal.targetRecordId)
if (own) own.push(proposal)
else byDocument.set(proposal.targetRecordId, [proposal])
}
return byDocument
}
/** A stable key for the line at a position of a document: what makes an extraction safe to run twice. */
export const lineKeyOf = (documentId: string, page: number, position: number): Promise<string> =>
sha256Hex(new TextEncoder().encode(`documents.line:${documentId}:${page}:${position}`))
/** The review screen's view of a proposal. */
export function lineViewOf(proposal: ProposalView): LineView {
const change = lineChangeSchema.parse(proposal.change)
return {
lineId: proposal.proposalId,
expectedVersion: proposal.version,
status: proposal.status,
label: change.label,
value: change.value,
page: change.page,
quote: change.quote,
box: proposal.evidence[0]?.box ?? null,
confidence: change.confidence,
verification: change.verification,
note: change.note ?? null,
blockId: proposal.evidence[0]?.blockId ?? null,
edited: change.edited,
model: proposal.model,
promptVersion: proposal.promptVersion,
decidedAt: proposal.decidedAt,
}
}
/** The lines a reviewer should see, in reading order, not creation order: the lines of one extraction are
* created within a millisecond of each other and their ids do not order them. */
export function visibleLines(proposals: readonly ProposalView[]): LineView[] {
return [...proposals]
.sort((a, b) => lineChangeSchema.parse(a.change).position - lineChangeSchema.parse(b.change).position)
.map(lineViewOf)
}
+54
View File
@@ -0,0 +1,54 @@
// What the extraction does with one page, decided from the reader's own classification (ocr-service
// OCR3.15) and nothing the model says: whether the page is read at all, whether the model is shown the
// image besides the text, and which of the page's lines make up the text it is shown.
//
// scan the recognised text is a reading, not a record: image and text, the text marked as low trust
// table image and text: the grid is better read than recognised
// text text alone, unless the page is weak by the reader's own measure (isWeakPage)
// drawing left out by default: tens of thousands of characters of labels, no specification in them
// empty left out by default
//
// A class in `excluded` is never read, whatever the rule above; the deployment chooses the list.
import type { PageClass } from "../contract/index.js"
import type { ReadPage } from "./ports.js"
import { isWeakPage } from "./verify.js"
export type PagePlan =
| { readonly kind: "skip" }
| {
readonly kind: "read"
/** The model should be shown the page image besides the text. */
readonly image: boolean
/** The text is a recognition of an image and may be wrong: the prompt says so, and the image wins. */
readonly lowTrustText: boolean
}
export function planPage(page: ReadPage, excluded: readonly PageClass[]): PagePlan {
if (excluded.includes(page.class)) return { kind: "skip" }
switch (page.class) {
case "scan":
return { kind: "read", image: true, lowTrustText: true }
case "table":
case "drawing":
return { kind: "read", image: true, lowTrustText: page.source === "ocr" }
case "text":
return { kind: "read", image: isWeakPage(page), lowTrustText: false }
case "empty":
return { kind: "read", image: false, lowTrustText: false }
}
}
/**
* The text of a page as the model is shown it: the lines in reading order without the ones the reader
* flagged as repeating on most pages (a running header, a footer, a page stamp), which are the
* document's furniture and not its content. A page the reader returned no lines for is its text as is.
*/
export function promptTextOf(page: ReadPage): string {
if (page.lines.length === 0) return page.text
return [...page.lines]
.sort((a, b) => a.order - b.order)
.filter((line) => !line.repeated)
.map((line) => line.text)
.join("\n")
}
+175
View File
@@ -0,0 +1,175 @@
// The ports the documents module's use cases are written against. Core imports no SDK (spec §2), so
// the shapes of an OCR result and of an extractor are declared here, structurally: the SDK's own
// `OcrDocument` is assignable to `ReadDocument`, and the adapter in adapters/ocr.ts is the one place
// that knows the SDK exists.
import { type TenantScope, token } from "@conusai-kit/core"
import type { FailureCode, LineNote, LineVerification, PageClass } from "../contract/index.js"
import type { StageCache } from "./stage-cache.js"
/** `[x0, y0, x1, y1]` in pixels of the page image, origin top-left. */
export type PixelBox = readonly [number, number, number, number]
export interface ReadLine {
/** The line as the reader returned it. A quote is this string, unchanged. */
readonly text: string
readonly bbox: PixelBox
/** 0 to 1. */
readonly confidence: number
/** Reading order within the page. */
readonly order: number
/** True when the line's text is on most pages of the document: a running header, footer or stamp, not the document's content. */
readonly repeated: boolean
}
/** A run of lines the reader grouped, with the stable id a citation points at. */
export interface ReadBlock {
/** `p{page}-b{n}`: the 1-based page of the source document and the block's place in reading order. */
readonly id: string
readonly bbox: PixelBox
readonly order: number
/** Indexes into the page's `lines`. */
readonly lines: readonly number[]
}
export interface ReadPage {
/** 0-based position in the source document. */
readonly index: number
readonly width: number
readonly height: number
/** What the page is mostly made of; decides whether the extraction reads it, and whether it is shown the image. */
readonly class: PageClass
/** `text_layer`: read from the file's own text; `ocr`: recognised from the page image (a scan). */
readonly source?: "ocr" | "text_layer" | "spreadsheet" | undefined
/** The page's text in reading order, one row per line. */
readonly text: string
readonly lines: readonly ReadLine[]
readonly blocks: readonly ReadBlock[]
}
export interface ReadDocument {
readonly engine: { readonly name: string; readonly version: string }
readonly page_count: number
readonly pages: readonly ReadPage[]
}
/** A box as fractions of the page, so it survives a render at any width (kit-proposals AT-D27). */
export interface FractionBox {
readonly x: number
readonly y: number
readonly width: number
readonly height: number
}
/** What an extractor proposes: a value, where it was read, and the words it was read from. */
export interface CandidateLine {
readonly label: string
readonly value: string
/** 1-based. */
readonly page: number
/** What the extractor quoted: verbatim from the page text, unless `verification` says it was not found there. */
readonly quote: string
/** The reader's id of the block the quote sits in, when the quote was found on the page's lines. */
readonly blockId?: string
/** The block's box (or, without a block, the box of the lines the quote falls on). */
readonly box?: FractionBox
/** The reader's confidence in the words, 0 to 1. */
readonly confidence: number
/** Set by code, never by a model: was the quote found on the page it cites. */
readonly verification: LineVerification
/** Why a reviewer should look at the line besides `verification`. */
readonly note?: LineNote
}
/** The image of one page, for a model that must read what the text layer could not. */
export interface PageImage {
readonly bytes: Uint8Array
/** `image/png` or `image/jpeg`. */
readonly mediaType: string
}
/** What an extractor is given: the read document, and what it needs to avoid paying twice. */
export interface ExtractionInput {
readonly document: ReadDocument
/** The image of a 0-based page, or undefined when there is none to give. */
readonly pageImage: (pageIndex: number) => Promise<PageImage | undefined>
/** Model replies are cached here by content hash, so a retried job pays for nothing twice. */
readonly cache: StageCache
readonly signal?: AbortSignal
}
/** The failure codes an extraction can end a document with. */
export type ExtractionFailureCode = Extract<FailureCode, `extraction_${string}`>
/**
* A permanent failure of the extraction, as a code the document records: the model declined
* (`extraction_refused`), kept running into its token ceiling (`extraction_truncated`) or never
* answered in the schema (`extraction_invalid`), each after one retry. Anything else an extractor
* throws is transient, and the queue retries the job.
*/
export class ExtractionFailed extends Error {
constructor(public readonly code: ExtractionFailureCode) {
super(`the extraction failed: ${code}`)
this.name = "ExtractionFailed"
}
}
/** A page the extraction did not read, and the class that left it out. */
export interface SkippedPage {
/** 1-based. */
readonly page: number
readonly pageClass: PageClass
}
/** What an extractor produced: the lines it proposes, and the pages it left out on purpose. */
export interface Extraction {
readonly lines: readonly CandidateLine[]
readonly skipped: readonly SkippedPage[]
}
/**
* Turns a read document into candidate lines. The template ships one behind the `ModelGateway`
* port (adapters/model-extractor.ts); a product replaces it -- a table reader, a domain's own rules,
* a different prompt -- by passing its own to the job, and nothing else in the module changes.
*/
export interface LineExtractor {
/** Recorded on every proposal as its prompt version: `<name>.v<n>`. */
readonly version: string
/** Recorded on every proposal as its producing model, besides the OCR engine. */
readonly name: string
/** Throws `ExtractionFailed` for a failure no retry would change. */
extract(input: ExtractionInput): Promise<Extraction>
}
/**
* Asynchronous OCR: `submit` returns when the platform has accepted the document, `wait` when it
* has read it. Two calls, because a retried job resumes a job it already submitted instead of
* paying for the document twice.
*/
export interface DocumentOcr {
submit(bytes: Uint8Array, options: { filename: string; idempotencyKey: string }): Promise<{ jobId: number }>
/** Throws `OcrJobFailed` when the platform says the job failed, which no retry will change. */
wait(jobId: number, options?: { signal?: AbortSignal }): Promise<ReadDocument>
/**
* The image of a 1-based page of a finished job, drawn by the platform. Undefined when the platform
* has none to give (the job's input expired, the page is past the end): the page is then read from its text.
*/
page(jobId: number, page: number, options?: { signal?: AbortSignal }): Promise<PageImage | undefined>
}
/** The platform read the document and could not: permanent, so the document is marked failed. */
export class OcrJobFailed extends Error {
constructor(message = "the OCR job failed") {
super(message)
this.name = "OcrJobFailed"
}
}
// An OCR read is metered and leaves the process: only an effect (a job) may resolve it.
export const DOCUMENT_OCR = token<DocumentOcr>("documents.ocr", { access: "effect" })
/**
* The project a job runs in, with a system actor. The worker binds it per job from the enqueueing
* scope (the same arrangement as `FILES_SCOPE`, decision AT-F6): a payload never names a tenant.
*/
export const DOCUMENTS_SCOPE = token<TenantScope>("documents.scope")
+304
View File
@@ -0,0 +1,304 @@
// What the OCR job does with one uploaded file: read it, extract candidate lines, and put each one
// in front of a person as a proposal. It runs in the worker, never in a request (AGENTS.md: a
// request enqueues and answers), and every step is safe to run again, because pg-boss retries a job
// that throws:
//
// - the document row is created once (unique on the file);
// - the OCR job id is stored the moment the platform accepts it, so a retry resumes that job and
// does not pay for the document twice (and the submit carries an idempotency key besides);
// - a candidate that already has a proposal, in any state, is not proposed again, so a line a
// person rejected does not come back and a crash half-way through adds only the rest;
// - what a stage produced (the OCR result, the job it came from, a model's reply for a page) is cached
// by the hash of its inputs, so the retry reads those instead of paying for them again, and finds the
// same lines;
// - a page image is asked of the platform only for a page the extractor decided to show a model, and
// only through the job that read the file.
//
// A failure no retry would change (the object is gone, the platform says the job failed, the model
// refused or never answered in the schema) is recorded on the document as a code and ends the job;
// anything else throws and is retried.
import { AppError } from "@conusai-kit/core"
import type { ObjectStore } from "@conusai-kit/data"
import { toInstant } from "@conusai-kit/data"
import { getFile, type ProcessorJobPayload, readAll, sha256Hex } from "@conusai-kit/files"
import { createProposal } from "@conusai-kit/proposals"
import { z } from "zod"
import { documents, type FailureCode, LINE_TARGET_KEY, type LineChange } from "../contract/index.js"
import { type DocumentScope, type DocumentsDeps, lineKeyOf, lineProposals } from "./lines.js"
import {
type DocumentOcr,
type Extraction,
ExtractionFailed,
type LineExtractor,
OcrJobFailed,
type SkippedPage,
} from "./ports.js"
import { projectDocument, readDocumentSchema } from "./read-document.js"
import { type StageCache, stageCache } from "./stage-cache.js"
const IMAGE_CONTENT_TYPES = ["image/png", "image/jpeg"]
/** The job that read a file, kept beside the OCR result so a file read once can still be shown page by page. */
const readJobSchema = z.object({ jobId: z.number().int().nonnegative() })
export interface ProcessDeps extends DocumentsDeps {
readonly store: ObjectStore
readonly ocr: DocumentOcr
readonly extractor: LineExtractor
/** Where stage artefacts and model replies are cached; by default the project's space in `store`. */
readonly cache?: StageCache
/** The most lines one document may propose: a review queue is for people, and a runaway reader must not fill it. */
readonly maxLines?: number
/** Maximum number of source bytes the job may buffer. */
readonly maxBytes?: number
readonly now?: () => number
/** Aborted when the queue takes the job back (a lapsed claim, a stopping worker): the wait ends with it. */
readonly signal?: AbortSignal
}
/** The database operations the extraction pipeline needs, each implemented as its own transaction. */
export interface DocumentProcessRecords {
getFile(scope: DocumentScope, fileId: string): ReturnType<typeof getFile>
ensureProcessing(
scope: DocumentScope,
file: Awaited<ReturnType<typeof getFile>>,
payload: ProcessorJobPayload,
): Promise<ProcessDocumentRow | undefined>
setOcrJob(scope: DocumentScope, document: ProcessDocumentRow, jobId: number): Promise<ProcessDocumentRow>
markFailed(scope: DocumentScope, document: ProcessDocumentRow, code: FailureCode, finishedAt: string): Promise<void>
proposals(scope: DocumentScope, documentId: string): ReturnType<typeof lineProposals>
createProposal(
scope: DocumentScope,
input: Parameters<typeof createProposal>[2],
idempotencyKey: string,
): Promise<void>
finish(
scope: DocumentScope,
document: ProcessDocumentRow,
result: { pageCount: number; proposedCount: number; skippedPages: SkippedPage[]; finishedAt: string },
): Promise<void>
}
export interface ProcessDocumentRow {
readonly id: string
readonly filename: string
readonly status: "processing" | "extracted" | "failed"
readonly ocrJobId: number | null
readonly version: number
}
export type ProcessOutcome = "extracted" | "failed" | "skipped"
const DEFAULT_MAX_LINES = 500
export async function processDocument(
deps: ProcessDeps,
scope: DocumentScope,
payload: ProcessorJobPayload,
): Promise<ProcessOutcome> {
return processDocumentWithRecords(
{ ...deps, maxBytes: deps.maxBytes ?? deps.files.policy.maxBytes },
scope,
payload,
createDataRecords(deps),
)
}
/**
* Run OCR and extraction over an external-effect port while record transitions remain explicit.
* The Convex profile implements each records method with one internal mutation/query; it never
* simulates a multi-request transaction around the whole pipeline.
*/
export async function processDocumentWithRecords(
deps: Omit<ProcessDeps, keyof DocumentsDeps>,
scope: DocumentScope,
payload: ProcessorJobPayload,
records: DocumentProcessRecords,
): Promise<ProcessOutcome> {
const clock = deps.now ?? Date.now
let file: Awaited<ReturnType<typeof getFile>>
try {
file = await records.getFile(scope, payload.fileId)
} catch (error) {
// Deleted between the upload and this job: nothing to read, and nothing a retry would find.
if (error instanceof AppError && error.status === 404) return "skipped"
throw error
}
const ensuredDocument = await records.ensureProcessing(scope, file, payload)
if (!ensuredDocument) return "skipped"
let activeDocument: ProcessDocumentRow = ensuredDocument
const fail = async (code: FailureCode): Promise<ProcessOutcome> => {
await records.markFailed(scope, activeDocument, code, toInstant(clock()))
return "failed"
}
const object = await deps.store.get(scope, payload.storageKey)
if (!object) return fail("file_missing")
// The most this job reads into memory is what an upload may be: the files policy's own ceiling.
const bytes = await readAll(object.body, deps.maxBytes ?? DEFAULT_FILE_BYTES)
// The bytes the job reads are the bytes the upload stored: a digest that moved is a different file.
if ((await sha256Hex(bytes)) !== payload.sha256) return fail("file_changed")
// Stage one, reading the file: a file already read (a retry, the same bytes uploaded again) is a
// cache hit and costs the platform nothing. The key is the file's own digest, which was just checked.
const cache = deps.cache ?? stageCache(deps.store, scope)
let read = await cache.get("ocr", payload.sha256, readDocumentSchema)
let readJobId = (await cache.get("ocr-job", payload.sha256, readJobSchema))?.jobId ?? activeDocument.ocrJobId
if (!read) {
let jobId = activeDocument.ocrJobId
if (jobId === null) {
const accepted = await deps.ocr.submit(bytes, {
filename: file.filename,
idempotencyKey: `documents.ocr.${payload.fileId}.${payload.sha256}`,
})
jobId = accepted.jobId
activeDocument = await records.setOcrJob(scope, activeDocument, jobId)
}
try {
read = projectDocument(await deps.ocr.wait(jobId, deps.signal ? { signal: deps.signal } : {}))
} catch (error) {
if (error instanceof OcrJobFailed) return fail("ocr_failed")
throw error
}
await cache.put("ocr", payload.sha256, read)
await cache.put("ocr-job", payload.sha256, { jobId })
readJobId = jobId
}
// Stage two, extraction: the extractor decides what is a line; a failure it calls permanent is the
// document's, recorded as its code, and any other is thrown for the queue to retry.
const isImage = IMAGE_CONTENT_TYPES.includes(file.contentType)
let extraction: Extraction
try {
extraction = await deps.extractor.extract({
document: read,
// An image upload is its own single page. A page of a PDF is drawn by the platform from the job
// that read it, on demand and only for a page the extractor asks for; a job the platform no
// longer holds has no image to give, and the page is then read from its text.
pageImage: async (index) => {
if (isImage) return index === 0 ? { bytes, mediaType: file.contentType } : undefined
if (readJobId === null) return undefined
return deps.ocr.page(readJobId, index + 1, deps.signal ? { signal: deps.signal } : {})
},
cache,
...(deps.signal ? { signal: deps.signal } : {}),
})
} catch (error) {
if (error instanceof ExtractionFailed) return fail(error.code)
throw error
}
const candidates = extraction.lines.slice(0, deps.maxLines ?? DEFAULT_MAX_LINES)
const proposed = new Set(
(await records.proposals(scope, activeDocument.id)).map(
(proposal) => (proposal.change as { lineKey?: string }).lineKey,
),
)
for (const [position, candidate] of candidates.entries()) {
const lineKey = await lineKeyOf(activeDocument.id, candidate.page, position)
if (proposed.has(lineKey)) continue
const change: LineChange = {
lineKey,
label: candidate.label,
value: candidate.value,
page: candidate.page,
position,
quote: candidate.quote,
confidence: candidate.confidence,
verification: candidate.verification,
...(candidate.note ? { note: candidate.note } : {}),
edited: false,
}
await records.createProposal(
scope,
{
targetKey: LINE_TARGET_KEY,
targetRecordId: activeDocument.id,
change,
evidence: [
{
fileId: payload.fileId,
page: candidate.page,
...(candidate.blockId ? { blockId: candidate.blockId } : {}),
...(candidate.box ? { box: candidate.box } : {}),
quote: candidate.quote,
},
],
model: `${read.engine.name}+${deps.extractor.name}`.slice(0, 128),
promptVersion: deps.extractor.version,
createdBy: scope.actor.userId,
},
`documents.line.${lineKey}`,
)
proposed.add(lineKey)
}
await records.finish(scope, activeDocument, {
pageCount: read.page_count,
proposedCount: proposed.size,
skippedPages: [...extraction.skipped],
finishedAt: toInstant(clock()),
})
return "extracted"
}
const DEFAULT_FILE_BYTES = 50 * 1024 * 1024
function createDataRecords(deps: ProcessDeps): DocumentProcessRecords {
return {
getFile: (scope, fileId) => getFile(deps.files, scope, fileId),
async ensureProcessing(scope, file, payload) {
const repo = deps.data.forScope(scope).of(documents)
const existing = (await repo.list({ where: [{ field: "fileId", op: "eq", value: payload.fileId }], limit: 1 }))
.items[0]
if (existing) return existing.status === "processing" ? existing : undefined
try {
return await repo.insert({
fileId: payload.fileId,
filename: file.filename,
status: "processing",
ocrJobId: null,
pageCount: null,
proposedCount: null,
failureCode: null,
skippedPages: null,
finishedAt: null,
})
} catch (error) {
if (!(error instanceof AppError && error.status === 409)) throw error
const raced = (await repo.list({ where: [{ field: "fileId", op: "eq", value: payload.fileId }], limit: 1 }))
.items[0]
if (!raced) throw error
return raced.status === "processing" ? raced : undefined
}
},
setOcrJob: (scope, document, jobId) =>
deps.data.forScope(scope).of(documents).update(document.id, { ocrJobId: jobId }, document.version),
async markFailed(scope, document, code, finishedAt) {
await deps.data
.forScope(scope)
.of(documents)
.update(document.id, { status: "failed", failureCode: code, finishedAt }, document.version)
},
proposals: (scope, documentId) => lineProposals(deps, scope, documentId),
async createProposal(scope, input, idempotencyKey) {
await createProposal(deps.proposals, scope, input, idempotencyKey)
},
async finish(scope, document, result) {
await deps.data
.forScope(scope)
.of(documents)
.update(
document.id,
{
status: "extracted",
pageCount: result.pageCount,
proposedCount: result.proposedCount,
skippedPages: result.skippedPages as never,
finishedAt: result.finishedAt,
},
document.version,
)
},
}
}
+42
View File
@@ -0,0 +1,42 @@
// The part of an OCR result this module reads, as a schema: what is kept as the OCR stage's cached
// artefact, so a file read once is not paid for twice. Words and polygons are not kept, and the
// SDK's own `OcrDocument` is assignable to the type this yields.
import { z } from "zod"
import { PAGE_CLASSES } from "../contract/index.js"
import type { ReadDocument } from "./ports.js"
const lineSchema = z.object({
text: z.string(),
bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]),
confidence: z.number(),
order: z.number(),
repeated: z.boolean(),
})
const blockSchema = z.object({
id: z.string().min(1),
bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]),
order: z.number(),
lines: z.array(z.number().int().min(0)),
})
const pageSchema = z.object({
index: z.number().int().min(0),
width: z.number(),
height: z.number(),
class: z.enum(PAGE_CLASSES),
source: z.enum(["ocr", "text_layer", "spreadsheet"]).optional(),
text: z.string(),
lines: z.array(lineSchema),
blocks: z.array(blockSchema),
})
export const readDocumentSchema: z.ZodType<ReadDocument> = z.object({
engine: z.object({ name: z.string(), version: z.string() }),
page_count: z.number().int().min(0),
pages: z.array(pageSchema),
})
/** What of a document is worth keeping: exactly the fields of `ReadDocument`, and no more. */
export const projectDocument = (document: ReadDocument): ReadDocument => readDocumentSchema.parse(document)
+134
View File
@@ -0,0 +1,134 @@
// What a reviewer reads and does: the document list, one document's lines, and an edit.
//
// Approving and rejecting are the proposals module's own procedures; this file adds only what that
// module has no word for. A document's status and counts are computed from the two places they
// live (the document row and the proposals), never stored twice.
import { notFound } from "@conusai-kit/core"
import { type FileView, getFile, listFiles } from "@conusai-kit/files"
import { editProposal, getProposal } from "@conusai-kit/proposals"
import {
type DocumentView,
documents,
LINE_TARGET_KEY,
type LineView,
lineChangeSchema,
READABLE_CONTENT_TYPES,
} from "../contract/index.js"
import {
type DocumentScope,
type DocumentsDeps,
lineProposals,
lineProposalsByDocument,
lineViewOf,
visibleLines,
} from "./lines.js"
const PAGE = 200
const readable = (file: FileView): boolean => (READABLE_CONTENT_TYPES as readonly string[]).includes(file.contentType)
async function allFiles(deps: DocumentsDeps, scope: DocumentScope): Promise<FileView[]> {
const found: FileView[] = []
let cursor: string | undefined
do {
const page = await listFiles(deps.files, scope, { limit: PAGE, ...(cursor === undefined ? {} : { cursor }) })
found.push(...page.files)
cursor = page.hasMore ? page.cursor : undefined
} while (cursor !== undefined)
return found.filter(readable)
}
async function allDocuments(deps: DocumentsDeps, scope: DocumentScope) {
const found = []
let cursor: string | undefined
do {
const page = await deps.data
.forScope(scope)
.of(documents)
.list({ limit: PAGE, ...(cursor === undefined ? {} : { cursor }) })
found.push(...page.items)
cursor = page.hasMore ? page.cursor : undefined
} while (cursor !== undefined)
return found
}
function viewOf(
file: FileView,
row: Awaited<ReturnType<typeof allDocuments>>[number] | undefined,
lines: readonly LineView[],
): DocumentView {
return {
fileId: file.fileId,
filename: file.filename,
// An upload the job has not picked up yet has no row: it is queued, which is a fact about the
// queue and not a state the module stores.
status: row?.status ?? "queued",
failureCode: row?.failureCode ?? null,
pageCount: row?.pageCount ?? null,
skippedPages: row?.skippedPages ?? [],
pending: lines.filter((line) => line.status === "pending").length,
approved: lines.filter((line) => line.status === "approved").length,
uploadedAt: file.storedAt,
}
}
export async function listDocuments(deps: DocumentsDeps, scope: DocumentScope): Promise<DocumentView[]> {
const [files, rows] = await Promise.all([allFiles(deps, scope), allDocuments(deps, scope)])
// The proposals of these documents only, pre-indexed by document id: one indexed read of the
// proposals table (its target index) and a Map lookup per document, instead of the project's
// whole queue read into memory and re-filtered for every row (O(D * P)). The list itself is
// answered whole because the `documents.document.list` contract takes no cursor; paging it
// through the procedure is a contract change, and the read that was unbounded was this one.
const byDocument = await lineProposalsByDocument(
deps,
scope,
rows.map((row) => row.id),
)
const byFile = new Map(rows.map((row) => [row.fileId, row]))
return files.map((file) => {
const row = byFile.get(file.fileId)
const own = row ? visibleLines(byDocument.get(row.id) ?? []) : []
return viewOf(file, row, own)
})
}
export async function getDocument(
deps: DocumentsDeps,
scope: DocumentScope,
fileId: string,
): Promise<{ document: DocumentView; lines: LineView[] }> {
// NotFound for another tenant's file id and for an id nobody has: the files module answers both
// the same way, and so does this one (spec §3.3).
const file = await getFile(deps.files, scope, fileId)
if (!readable(file)) throw notFound("no such document")
const row = (
await deps.data
.forScope(scope)
.of(documents)
.list({ where: [{ field: "fileId", op: "eq", value: file.fileId }], limit: 1 })
).items[0]
const lines = row ? visibleLines(await lineProposals(deps, scope, row.id)) : []
return { document: viewOf(file, row, lines), lines }
}
/**
* A reviewer's correction of a pending line: the proposals module replaces the proposal's change with
* the corrected one (validated by this module's own schema, audited, and refused once decided), and
* what is approved afterwards is what the reviewer saw. The line keeps its id.
*/
export async function editLine(
deps: DocumentsDeps,
scope: DocumentScope,
input: { lineId: string; label: string; value: string },
): Promise<LineView> {
const current = await getProposal(deps.proposals, scope, input.lineId)
if (current.targetKey !== LINE_TARGET_KEY) throw notFound("no such line")
const change = lineChangeSchema.parse(current.change)
const edited = await editProposal(deps.proposals, scope, current.proposalId, {
...change,
label: input.label,
value: input.value,
edited: true,
})
return lineViewOf(edited)
}
+61
View File
@@ -0,0 +1,61 @@
// A cache of what a stage of the job produced, by the hash of what it was produced from.
//
// Two stages are expensive and deterministic in their inputs: reading a file (OCR) and asking a model
// about a page. Each result is stored under the SHA-256 of everything that went into it, so a retried
// job, a re-uploaded file and an evaluation run again pay for nothing twice, and a change to any input
// (the bytes, the prompt version, the model, the schema) is a different key and so a miss. Objects live
// in the project's own space of the object store: a hash is no reason for one tenant to see another's.
//
// It is an optimisation and never a source of truth: an entry that is gone, unreadable or no longer
// matches its schema is a miss, and the stage runs.
import type { ObjectStore } from "@conusai-kit/data"
import { readAll, sha256Hex } from "@conusai-kit/files"
import type { z } from "zod"
import type { DocumentScope } from "./lines.js"
const MAX_ENTRY_BYTES = 16 * 1024 * 1024
/** The hex SHA-256 of any JSON-serialisable parts, in order: the key of a stage's result. */
export const hashOf = (...parts: readonly unknown[]): Promise<string> =>
sha256Hex(new TextEncoder().encode(JSON.stringify(parts)))
export interface StageCache {
/** The stored value of a stage for a key, checked against `schema`; undefined on any miss. */
get<S extends z.ZodType>(stage: string, key: string, schema: S): Promise<z.output<S> | undefined>
put(stage: string, key: string, value: unknown): Promise<void>
}
export function stageCache(store: ObjectStore, scope: DocumentScope): StageCache {
const keyOf = (stage: string, key: string): string =>
`documents/cache/${scope.workspaceId}/${scope.projectId}/${stage}/${key}.json`
return {
async get(stage, key, schema) {
const object = await store.get(scope, keyOf(stage, key))
if (!object) return undefined
try {
const parsed = schema.safeParse(
JSON.parse(new TextDecoder().decode(await readAll(object.body, MAX_ENTRY_BYTES))),
)
return parsed.success ? parsed.data : undefined
} catch {
return undefined
}
},
async put(stage, key, value) {
await store.put(scope, {
key: keyOf(stage, key),
body: new TextEncoder().encode(JSON.stringify(value)),
contentType: "application/json",
})
},
}
}
/** A cache that remembers nothing, for a caller that wants every stage run. */
export const noCache: StageCache = {
async get() {
return undefined
},
async put() {},
}
+36
View File
@@ -0,0 +1,36 @@
// The proposal target of this module: the code that writes an approved line.
//
// kit-proposals runs `apply` inside the approving transaction, on the approver's own repositories,
// so the line and the proposal's new status commit together or not at all. That is the only way a
// row reaches `documents_line`, which is why the export, which reads that table alone, cannot
// contain a line nobody approved.
import { notFound } from "@conusai-kit/core"
import type { ProposalTarget } from "@conusai-kit/proposals"
import { documents, LINE_TARGET_KEY, lineChangeSchema, lines } from "../contract/index.js"
export const lineTarget: ProposalTarget = {
key: LINE_TARGET_KEY,
change: lineChangeSchema,
// Checked before `apply` runs, so approving a proposal is never a way around this module's own
// write permission (decision AT-D29).
permission: "documents.line.approve",
async apply(tx, scope, write) {
if (write.targetRecordId === null) throw notFound("no such document")
const change = lineChangeSchema.parse(write.change)
const document = await tx.of(documents).get(write.targetRecordId)
if (!document) throw notFound("no such document")
const line = await tx.of(lines).insert({
documentId: document.id,
proposalId: write.proposalId,
label: change.label,
value: change.value,
page: change.page,
position: change.position,
quote: change.quote,
edited: change.edited,
approvedBy: scope.actor.userId,
})
return { recordId: line.id }
},
}
+113
View File
@@ -0,0 +1,113 @@
// What code, not a model, decides about a line a model proposed: whether its quote is on the page it
// cites, where on the page it sits, how far the reader that produced those words can be trusted, and
// whether a page is one a model should be shown as an image.
//
// A model is asked for a verbatim quote because a quote can be checked. This is the check. A line
// whose quote is not found is not dropped (it may be a correct line with a misread quote) and is not
// trusted (it may be invented): it is kept, marked `needs_review`, and a person decides.
import type { FractionBox, PixelBox, ReadLine, ReadPage } from "./ports.js"
/**
* The form two strings are compared in, and used for nothing else: a quote is stored and shown as the
* model gave it. Whitespace runs collapse, case is folded, and combining marks are dropped after a
* canonical decomposition, so `Šarūnas` and `sarunas` meet. Nothing else is folded: punctuation, digits
* and letters that are different letters stay different, because a number read as another number is
* exactly the error this check exists to find.
*/
export function foldForComparison(text: string): string {
return text
.normalize("NFD")
.replace(/\p{M}+/gu, "")
.toLowerCase()
.replace(/\s+/gu, " ")
.trim()
}
/** True when the quote, folded, is a non-empty part of the page's text, folded. */
export function quoteIsOnPage(pageText: string, quote: string): boolean {
const wanted = foldForComparison(quote)
return wanted !== "" && foldForComparison(pageText).includes(wanted)
}
const clamp = (value: number): number => Math.min(1, Math.max(0, value))
export interface Location {
/** The union of the boxes of the blocks the quote runs over, as fractions of the page; without blocks, of the lines it falls on. None when the page has no size. */
readonly box?: FractionBox
/** The reader's id of the block holding the first line the quote falls on, when the reader grouped lines into blocks. */
readonly blockId?: string
/** The mean confidence of those lines. */
readonly confidence: number
}
function boxOf(page: ReadPage, boxes: readonly PixelBox[]): FractionBox | undefined {
if (!(page.width > 0) || !(page.height > 0) || boxes.length === 0) return undefined
const x0 = Math.min(...boxes.map((box) => box[0]))
const y0 = Math.min(...boxes.map((box) => box[1]))
const x1 = Math.max(...boxes.map((box) => box[2]))
const y1 = Math.max(...boxes.map((box) => box[3]))
const x = clamp(x0 / page.width)
const y = clamp(y0 / page.height)
// The box stays inside the page: a reader's box may overhang its edge by a pixel.
const width = Math.min(clamp((x1 - x0) / page.width), 1 - x)
const height = Math.min(clamp((y1 - y0) / page.height), 1 - y)
// A zero-area box is no locator: the proposal's evidence schema requires width and height > 0.
return width > 0 && height > 0 ? { x, y, width, height } : undefined
}
const mean = (values: readonly number[]): number =>
values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
/**
* Where a quote sits on a page, from the reader's own lines: the lines it overlaps, in reading order.
* Undefined when the quote does not fall on the lines (the page's text and its lines disagree); the
* line is then still verified by the text, and simply has no box.
*/
export function locateQuote(page: ReadPage, quote: string): Location | undefined {
const wanted = foldForComparison(quote)
if (wanted === "") return undefined
const ordered = [...page.lines].sort((a, b) => a.order - b.order)
const spans: Array<{ line: ReadLine; start: number; end: number }> = []
let joined = ""
for (const line of ordered) {
const folded = foldForComparison(line.text)
if (folded === "") continue
if (joined !== "") joined += " "
spans.push({ line, start: joined.length, end: joined.length + folded.length })
joined += folded
}
const at = joined.indexOf(wanted)
if (at < 0) return undefined
const end = at + wanted.length
const hit = spans.filter((span) => span.start < end && span.end > at).map((span) => span.line)
if (hit.length === 0) return undefined
// A quote is located by the reader's blocks when it gave them: the id of the block holding its first
// line, and the boxes of the blocks it runs over, not a box made up from the lines it happened to fall on.
const indexes = hit.map((line) => page.lines.indexOf(line))
const blocks = page.blocks.filter((candidate) => indexes.some((index) => candidate.lines.includes(index)))
const first = blocks.find((candidate) => candidate.lines.includes(indexes[0] as number))
const box = boxOf(page, blocks.length > 0 ? blocks.map((block) => block.bbox) : hit.map((line) => line.bbox))
return {
...(box ? { box } : {}),
...(first ? { blockId: first.id } : {}),
confidence: clamp(mean(hit.map((line) => line.confidence))),
}
}
/** The mean confidence of a page's lines; 0 for a page with none. */
export const pageConfidence = (page: ReadPage): number => clamp(mean(page.lines.map((line) => line.confidence)))
/** Below this a page's text is a reading, not a record: a model is shown the image too. */
export const WEAK_PAGE_CONFIDENCE = 0.85
/** A page with less text than this has no text layer worth the name. */
export const MIN_PAGE_TEXT_CHARS = 20
/** A scan, a page the reader was unsure of, or a page with next to no text: one a model should see. */
export function isWeakPage(page: ReadPage): boolean {
return (
page.source === "ocr" ||
page.text.trim().length < MIN_PAGE_TEXT_CHARS ||
pageConfidence(page) < WEAK_PAGE_CONFIDENCE
)
}
+186
View File
@@ -0,0 +1,186 @@
// A workbook writer: the one thing the export needs, in ~100 lines and no dependency.
//
// An .xlsx file is a ZIP of a few XML parts. This writes the five parts a spreadsheet application
// needs (plus a styles part for the bold header row) as an uncompressed ZIP, which every reader
// accepts. Choosing it over a library is a supply-chain decision (spec §12): the template's export
// is a table of strings and numbers, and a general workbook library is thousands of lines of code
// in a customer's dependency tree for that.
//
// Two properties are deliberate and tested. Every string is an inline string, so a value that
// begins `=`, `+`, `-` or `@` is text a spreadsheet displays and never a formula it evaluates (a
// document's text is attacker-controlled input). And the output is a pure function of its input
// -- fixed timestamps, fixed order -- so the same lines are the same bytes.
export type Cell = string | number
const encoder = new TextEncoder()
/** CRC-32 (IEEE), as ZIP requires. */
const CRC_TABLE = ((): Uint32Array => {
const table = new Uint32Array(256)
for (let n = 0; n < 256; n += 1) {
let c = n
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
table[n] = c >>> 0
}
return table
})()
function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff
for (const byte of bytes) crc = (CRC_TABLE[(crc ^ byte) & 0xff] as number) ^ (crc >>> 8)
return (crc ^ 0xffffffff) >>> 0
}
/** Characters XML 1.0 cannot carry at all, removed rather than escaped. */
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching control characters is the point.
const NOT_XML = /[\u0000-\u0008\u000b\u000c\u000e-\u001f￾￿]/gu
const escapeXml = (text: string): string =>
text.replace(NOT_XML, "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
/** `0 -> A`, `25 -> Z`, `26 -> AA`. */
function columnName(index: number): string {
let name = ""
for (let n = index + 1; n > 0; n = Math.floor((n - 1) / 26)) name = String.fromCharCode(65 + ((n - 1) % 26)) + name
return name
}
const XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
function sheetXml(rows: readonly (readonly Cell[])[]): string {
const body = rows
.map((row, r) => {
const cells = row
.map((cell, c) => {
const ref = `${columnName(c)}${r + 1}`
if (typeof cell === "number") {
if (!Number.isFinite(cell)) throw new Error(`a workbook cell must be a finite number: ${ref}`)
return `<c r="${ref}"><v>${cell}</v></c>`
}
// The first row is the header and takes the bold style.
return `<c r="${ref}" t="inlineStr"${r === 0 ? ' s="1"' : ""}><is><t xml:space="preserve">${escapeXml(cell)}</t></is></c>`
})
.join("")
return `<row r="${r + 1}">${cells}</row>`
})
.join("")
return `${XML}<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${body}</sheetData></worksheet>`
}
const STYLES =
`${XML}<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +
'<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>' +
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>' +
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
'<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' +
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs></styleSheet>'
const CONTENT_TYPES =
`${XML}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>'
const ROOT_RELS =
`${XML}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>'
const WORKBOOK_RELS =
`${XML}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>'
const workbookXml = (sheetName: string): string =>
`${XML}<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
`<sheets><sheet name="${escapeXml(sheetName)}" sheetId="1" r:id="rId1"/></sheets></workbook>`
/** What Excel refuses in a sheet name: empty, over 31 characters, or one of `[]:*?/\`. */
const BAD_SHEET_NAME = /[[\]:*?/\\]/u
interface Part {
readonly name: string
readonly data: Uint8Array
}
/** DOS date 1980-01-01, time 00:00: a fixed stamp keeps the output reproducible. */
const DOS_TIME = 0
const DOS_DATE = (0 << 9) | (1 << 5) | 1
function zip(parts: readonly Part[]): Uint8Array {
const chunks: Uint8Array[] = []
const central: Uint8Array[] = []
let offset = 0
for (const part of parts) {
const name = encoder.encode(part.name)
const crc = crc32(part.data)
const local = new Uint8Array(30 + name.length)
const lv = new DataView(local.buffer)
lv.setUint32(0, 0x04034b50, true)
lv.setUint16(4, 20, true) // version needed
lv.setUint16(6, 0x0800, true) // UTF-8 names
lv.setUint16(8, 0, true) // stored
lv.setUint16(10, DOS_TIME, true)
lv.setUint16(12, DOS_DATE, true)
lv.setUint32(14, crc, true)
lv.setUint32(18, part.data.length, true)
lv.setUint32(22, part.data.length, true)
lv.setUint16(26, name.length, true)
local.set(name, 30)
chunks.push(local, part.data)
const entry = new Uint8Array(46 + name.length)
const cv = new DataView(entry.buffer)
cv.setUint32(0, 0x02014b50, true)
cv.setUint16(4, 20, true) // version made by
cv.setUint16(6, 20, true)
cv.setUint16(8, 0x0800, true)
cv.setUint16(10, 0, true)
cv.setUint16(12, DOS_TIME, true)
cv.setUint16(14, DOS_DATE, true)
cv.setUint32(16, crc, true)
cv.setUint32(20, part.data.length, true)
cv.setUint32(24, part.data.length, true)
cv.setUint16(28, name.length, true)
cv.setUint32(42, offset, true)
entry.set(name, 46)
central.push(entry)
offset += local.length + part.data.length
}
const directorySize = central.reduce((sum, entry) => sum + entry.length, 0)
const end = new Uint8Array(22)
const ev = new DataView(end.buffer)
ev.setUint32(0, 0x06054b50, true)
ev.setUint16(8, parts.length, true)
ev.setUint16(10, parts.length, true)
ev.setUint32(12, directorySize, true)
ev.setUint32(16, offset, true)
const all = [...chunks, ...central, end]
const out = new Uint8Array(all.reduce((sum, chunk) => sum + chunk.length, 0))
let at = 0
for (const chunk of all) {
out.set(chunk, at)
at += chunk.length
}
return out
}
/** One sheet of rows; the first row is the header. */
export function buildWorkbook(sheetName: string, rows: readonly (readonly Cell[])[]): Uint8Array {
if (sheetName.length === 0 || sheetName.length > 31 || BAD_SHEET_NAME.test(sheetName)) {
throw new Error("a workbook sheet name is 1 to 31 characters without [ ] : * ? / \\")
}
const text = (name: string, xml: string): Part => ({ name, data: encoder.encode(xml) })
return zip([
text("[Content_Types].xml", CONTENT_TYPES),
text("_rels/.rels", ROOT_RELS),
text("xl/workbook.xml", workbookXml(sheetName)),
text("xl/_rels/workbook.xml.rels", WORKBOOK_RELS),
text("xl/styles.xml", STYLES),
text("xl/worksheets/sheet1.xml", sheetXml(rows)),
])
}