Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "pdf-codec",
"version": "2.2.36",
"description": "Hand-written, dependency-minimal PDF codec: parses arbitrary real-world PDFs and generates new ones, built on document-schema.js's LayoutDocument pivot and Zod 4 codecs.",
"description": "Hand-written, dependency-minimal PDF codec: parses arbitrary real-world PDFs and generates new ones, built on its own codec-owned LayoutDocument item model and Zod 4 codecs.",
"type": "module",
"repository": {
"type": "git",
Expand Down Expand Up @@ -81,7 +81,7 @@
"packageManager": "pnpm@11.6.0",
"dependencies": {
"byte-codec": "^1.1.8",
"document-schema.js": "^3.3.0",
"document-schema.js": "^4.0.0",
"fflate": "^0.8.3",
"zod": "^4.4.3"
},
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/codec.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { describe, expect, it } from 'vitest';
import { LayoutDocumentSchema } from 'document-schema.js';
import { LayoutDocumentSchema } from './layout';
import { minimalClassicXrefPdf } from './test-support/pdf';
import { pdfCodec } from './codec';
import { readPdf } from './read';
Expand Down
2 changes: 1 addition & 1 deletion src/codec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { LayoutDocumentSchema } from 'document-schema.js';
import { readPdf } from './read';
import { writePdf } from './write';
import { LayoutDocumentSchema } from './layout';

// '%PDF-' -- the PDF header (ISO 32000-1 section 7.5.2). Per the spec it may be preceded by arbitrary bytes (some producers prepend a comment or BOM), so this checks for the signature within the first kilobyte rather than requiring it at offset 0. A standalone, independently-duplicated copy of documents.js's own src/model/bytes.ts PdfBytesSchema logic -- that file is co-located there alongside unrelated docx/pptx/odt schemas which must stay in documents.js, so this package owns its own narrow ~20-line copy of just the PDF-specific check rather than importing the whole thing.
const PDF_HEADER = [0x25, 0x50, 0x44, 0x46, 0x2d];
Expand Down
3 changes: 2 additions & 1 deletion src/content-write.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { LayoutEllipse, LayoutImage, LayoutLine, LayoutLink, LayoutRect, LayoutText, TextMeasurer } from 'document-schema.js';
import type { TextMeasurer } from 'document-schema.js';
import type { LayoutEllipse, LayoutImage, LayoutLine, LayoutLink, LayoutRect, LayoutText } from './layout';
import type { ContentWriteContext } from './content-write';
import { writeContentStream } from './content-write';
import type { EmbeddedFace } from './embedded-font';
Expand Down
9 changes: 4 additions & 5 deletions src/content-write.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { LayoutEllipse, LayoutImage, LayoutItem, LayoutLine, LayoutPath, LayoutRect, LayoutSubpath, LayoutText } from 'document-schema.js';
import type { Color as LayoutColor } from 'document-schema.js';
import type { LayoutFont, TextMeasurer, UnderlineMetrics } from 'document-schema.js';
import type { Color as LayoutColor, LayoutFont, TextMeasurer, UnderlineMetrics } from 'document-schema.js';
import type { LayoutEllipse, LayoutImage, LayoutItem, LayoutLine, LayoutPath, LayoutRect, LayoutSubpath, LayoutText } from './layout';
import type { StandardFontName } from './afm-widths';
import { ByteWriter } from './bytes/writer';
import type { EmbeddedFace, EmbeddedFaceSubstitution, EmbeddedShow } from './embedded-font';
Expand Down Expand Up @@ -172,7 +171,7 @@ function formatPoint(x: number, y: number): string {
return `${formatNumber(x)} ${formatNumber(y)}`;
}

// A LayoutLine's/LayoutPath's own stroke style (document-schema.js 2.1's `style` field). 'solid' and an absent field are the same thing: the PDF graphics state's own defaults, with nothing emitted for either.
// A LayoutLine's/LayoutPath's own stroke style (the `style` field on the item schemas in src/layout.ts). 'solid' and an absent field are the same thing: the PDF graphics state's own defaults, with nothing emitted for either.
type StrokeStyle = NonNullable<LayoutLine['style']>;

// Dash-pattern lengths (ISO 32000-1 8.4.3.6, the 'd' operator) are expressed as multiples of the stroke's OWN width rather than as fixed point lengths, so a hairline rule and a thick one both read as recognisably dashed: a fixed [3 3] pattern under a 6pt stroke paints overlapping blocks that read as solid, and under a 0.25pt one paints dashes twelve times longer than they are thick.
Expand Down Expand Up @@ -385,7 +384,7 @@ function writeEllipse(writer: ByteWriter, item: LayoutEllipse): void {
writer.writeAscii(`${paint}\n`);
}

// One subpath: m (moveto the subpath's own starting point), then l/c per segment, then h if the subpath is closed. No quadratic-to-cubic elevation and no SVG elliptical-arc endpoint-to-center parameterization exist anywhere in this module, deliberately: LayoutPathSegment's own discriminated union (document-schema.js's layout.ts) only ever has 'line'/'cubic' variants, because the sole real-world producer of a LayoutPath -- odf.js's own svg:d/draw:points parser (typed/shared/path.ts), verified against genuine LibreOffice output -- never emits a quadratic or an arc segment in the first place: ODF's own svg:d grammar recognises S/s, Q/q, T/t, A/a as command letters (so the token stream stays in sync) but that parser explicitly produces no segment for any of them, real LibreOffice output for rectangles/ellipses/freeform curves/basic custom-shape presets never exercises them, and ContentPathSegmentSchema itself only models 'line'/'cubic' regardless. There is nothing here to elevate or parameterize, and building that conversion code with no caller would be unused code kept "just in case".
// One subpath: m (moveto the subpath's own starting point), then l/c per segment, then h if the subpath is closed. No quadratic-to-cubic elevation and no SVG elliptical-arc endpoint-to-center parameterization exist anywhere in this module, deliberately: LayoutPathSegment's own discriminated union (src/layout.ts) only ever has 'line'/'cubic' variants, because the sole real-world producer of a LayoutPath -- odf.js's own svg:d/draw:points parser (typed/shared/path.ts), verified against genuine LibreOffice output -- never emits a quadratic or an arc segment in the first place: ODF's own svg:d grammar recognises S/s, Q/q, T/t, A/a as command letters (so the token stream stays in sync) but that parser explicitly produces no segment for any of them, real LibreOffice output for rectangles/ellipses/freeform curves/basic custom-shape presets never exercises them, and ContentPathSegmentSchema itself only models 'line'/'cubic' regardless. There is nothing here to elevate or parameterize, and building that conversion code with no caller would be unused code kept "just in case".
function writeSubpath(writer: ByteWriter, subpath: LayoutSubpath): void {
writer.writeAscii(`${formatPoint(subpath.startXPt, subpath.startYPt)} m\n`);
for (const segment of subpath.segments) {
Expand Down
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// pdf-codec's public surface: a curated barrel export, no subpath exports, matching document-schema.js/odf.js/ooxml.js's own precedent. What's exported here is every symbol a real external consumer needs -- headline read/write/codec entry points, the formula/math port documents.js's own MathML layout engine passes real values through, the text-layout and font-resolution helpers every layout engine built on this codec needs, and the full bytes/image surface (this package owns src/bytes/ and src/image/ outright; nothing duplicates them upstream). Internal plumbing (objects.ts, serialize.ts, lexer.ts, parse.ts, xref.ts, document.ts, interpret.ts, content-read.ts, content-write.ts, filters.ts, predictors.ts, images-read.ts, cmap.ts, font-read.ts, font-style.ts, and the cmap-table/hmtx-table/font-tables/glyf/sfnt/sfnt-subset/cff/cff-probe/cff-bounds/tounicode/ot-layout-common/gpos-table/math-font-write/math-content-write/embedded-font-write font-parsing, font-subsetting, and font-embedding internals) stays unexported -- math-table.ts is a partial exception, exporting its MathVariants types alone (see below), and glyph-bounds.ts another, exporting the GlyphInkBounds shape those outline readers report through -- nothing outside this package's own src/ consumes it today. embedded-font.ts is the one partial exception: its EmbeddedFace is the type ResolvedFace's own 'embedded' variant carries, and its EmbeddedFaceSubstitution is what WritePdfOptions.onMissingGlyph reports, so both must be nameable by an external caller even though nothing else in that module is exported.
// pdf-codec's public surface: a curated barrel export, no subpath exports, matching document-schema.js/odf.js/ooxml.js's own precedent. What's exported here is every symbol a real external consumer needs -- headline read/write/codec entry points, the Layout item family this package owns outright as its native document model (src/layout.ts, exported wholesale below), the formula/math port documents.js's own MathML layout engine passes real values through, the text-layout and font-resolution helpers every layout engine built on this codec needs, and the full bytes/image surface (this package owns src/bytes/ and src/image/ outright; nothing duplicates them upstream). Internal plumbing (objects.ts, serialize.ts, lexer.ts, parse.ts, xref.ts, document.ts, interpret.ts, content-read.ts, content-write.ts, filters.ts, predictors.ts, images-read.ts, cmap.ts, font-read.ts, font-style.ts, and the cmap-table/hmtx-table/font-tables/glyf/sfnt/sfnt-subset/cff/cff-probe/cff-bounds/tounicode/ot-layout-common/gpos-table/math-font-write/math-content-write/embedded-font-write font-parsing, font-subsetting, and font-embedding internals) stays unexported -- math-table.ts is a partial exception, exporting its MathVariants types alone (see below), and glyph-bounds.ts another, exporting the GlyphInkBounds shape those outline readers report through -- nothing outside this package's own src/ consumes it today. embedded-font.ts is the one partial exception: its EmbeddedFace is the type ResolvedFace's own 'embedded' variant carries, and its EmbeddedFaceSubstitution is what WritePdfOptions.onMissingGlyph reports, so both must be nameable by an external caller even though nothing else in that module is exported.

// Headline: read/write/diagnostics/codec.
export type { ReadPdfOptions } from './read';
Expand All @@ -10,7 +10,10 @@ export { NOOP_DIAGNOSTIC_SINK, PdfEncryptedError, PdfParseError, PdfPasswordRequ
export type { WinAnsiSubstitution } from './winansi';
export { PdfBytesSchema, pdfCodec } from './codec';

// Formula/math: the structural port documents.js's own MathML layout engine (layoutFormula, staying in documents.js) produces real values against -- see src/math-types.ts for the full rationale.
// The Layout item family: LayoutDocument and every item/page/image-asset schema, inferred type, and LAYOUT_FORMAT_VERSION -- pdf-codec's own native document model, ported from document-schema.js (which dropped it at its 4.0.0) per the family pattern where a codec's native model lives in the codec, like ooxml.js's Package/XmlElement. Exported wholesale because every symbol in src/layout.ts is public family surface: readPdf/writePdf's own signatures speak these types, and documents.js re-exports the family onward from its own barrel. Callers that imported the family from document-schema.js pre-4.0.0 import the same names from pdf-codec now.
export * from './layout';

// Formula/math: the structural port documents.js's own MathML layout engine (layoutFormula, staying in documents.js) produces real values against -- the family lives in document-schema.js's math layout port, one shared definition across the family rather than a local mirror (importing it from documents.js itself would be circular once documents.js depends on this package).
export type { MathAssembledGlyphs, MathBox, MathColor, MathFontMetrics, MathGlyphMetrics, MathGlyphPlacement, MathGlyphRun, MathLayoutItem, MathRule, MathStretchAxis, MathStretchGlyph, MathStretchResult, MathStroke, PositionedFormula } from 'document-schema.js';
export type { LoadedMathFont, MathFont, MathFontDescriptorMetrics } from './math-font';
export { loadMathFont } from './math-font';
Expand Down
216 changes: 216 additions & 0 deletions src/layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, expect, it } from 'vitest';
import { COLOR_BLACK, DEFAULT_LAYOUT_FONT } from 'document-schema.js';
import {
LAYOUT_FORMAT_VERSION,
type LayoutDocument,
LayoutDocumentSchema,
type LayoutItem,
LayoutItemSchema,
} from './layout';

const text: LayoutItem = {
kind: 'text',
text: 'Hello, layout.',
xPt: 72,
yPt: 720,
font: DEFAULT_LAYOUT_FONT,
sizePt: 12,
color: COLOR_BLACK,
widthPt: 90.5,
rotationDeg: 0,
underline: true,
};

const imageItem: LayoutItem = {
kind: 'image',
imageId: 'logo',
xPt: 10,
yPt: 700,
widthPt: 50,
heightPt: 25,
rotationDeg: 5,
};

const rect: LayoutItem = {
kind: 'rect',
xPt: 0,
yPt: 0,
widthPt: 200,
heightPt: 100,
fill: { r: 0.9, g: 0.9, b: 0.9 },
stroke: { color: COLOR_BLACK, widthPt: 1.5 },
};

const line: LayoutItem = {
kind: 'line',
x1Pt: 0,
y1Pt: 0,
x2Pt: 100,
y2Pt: 100,
color: COLOR_BLACK,
widthPt: 2,
};

const ellipse: LayoutItem = {
kind: 'ellipse',
xPt: 20,
yPt: 20,
widthPt: 40,
heightPt: 40,
fill: { r: 0.1, g: 0.2, b: 0.3 },
};

const path: LayoutItem = {
kind: 'path',
subpaths: [
{
startXPt: 0,
startYPt: 0,
closed: true,
segments: [
{ kind: 'line', xPt: 10, yPt: 0 },
{ kind: 'cubic', c1xPt: 15, c1yPt: 5, c2xPt: 15, c2yPt: 15, xPt: 10, yPt: 20 },
{ kind: 'line', xPt: 0, yPt: 20 },
],
},
],
fill: { r: 0.4, g: 0.5, b: 0.6 },
fillRule: 'evenodd',
stroke: { color: COLOR_BLACK, widthPt: 1 },
};

const link: LayoutItem = {
kind: 'link',
uri: 'https://example.com/',
xPt: 5,
yPt: 5,
widthPt: 60,
heightPt: 15,
};

describe('LayoutItemSchema', () => {
it('accepts every item kind and preserves every field through a JSON round trip', () => {
for (const item of [text, imageItem, rect, line, ellipse, path, link]) {
const parsed = LayoutItemSchema.parse(item);
const roundTripped: unknown = JSON.parse(JSON.stringify(parsed));
expect(LayoutItemSchema.parse(roundTripped)).toEqual(item);
}
});

it('rejects an unknown kind', () => {
expect(LayoutItemSchema.safeParse({ kind: 'circle', xPt: 0, yPt: 0 }).success).toBe(false);
});
});

describe('LayoutPathSchema', () => {
it('accepts a minimal open subpath with no fill, stroke, or fillRule', () => {
const minimal: LayoutItem = {
kind: 'path',
subpaths: [{ startXPt: 0, startYPt: 0, closed: false, segments: [{ kind: 'line', xPt: 10, yPt: 10 }] }],
};
expect(LayoutItemSchema.parse(minimal)).toEqual(minimal);
});

it('accepts a path with multiple subpaths, matching an evenodd hole punched through a fill', () => {
const withHole: LayoutItem = {
kind: 'path',
subpaths: [
{ startXPt: 0, startYPt: 0, closed: true, segments: [{ kind: 'line', xPt: 20, yPt: 0 }, { kind: 'line', xPt: 20, yPt: 20 }, { kind: 'line', xPt: 0, yPt: 20 }] },
{ startXPt: 5, startYPt: 5, closed: true, segments: [{ kind: 'line', xPt: 15, yPt: 5 }, { kind: 'line', xPt: 15, yPt: 15 }, { kind: 'line', xPt: 5, yPt: 15 }] },
],
fill: COLOR_BLACK,
fillRule: 'evenodd',
};
expect(LayoutItemSchema.parse(withHole)).toEqual(withHole);
});

it('rejects a segment kind other than line/cubic', () => {
const invalid = { kind: 'path', subpaths: [{ startXPt: 0, startYPt: 0, closed: false, segments: [{ kind: 'quadratic', xPt: 1, yPt: 1 }] }] };
expect(LayoutItemSchema.safeParse(invalid).success).toBe(false);
});
});

describe('LayoutItemSchema sourcePath', () => {
it('survives a JSON round trip when set on every item kind', () => {
const itemsWithSourcePath: LayoutItem[] = [
{ ...text, sourcePath: 'sections[0].blocks[0].runs[0]' },
{ ...imageItem, sourcePath: 'sections[0].blocks[1]' },
{ ...rect, sourcePath: 'slides[0].shapes[0]' },
{ ...line, sourcePath: 'slides[0].shapes[1]' },
{ ...ellipse, sourcePath: 'slides[0].shapes[2]' },
{ ...path, sourcePath: 'pages[0].vectors[0]' },
{ ...link, sourcePath: 'sections[0].blocks[0].runs[1]' },
];
for (const item of itemsWithSourcePath) {
const parsed = LayoutItemSchema.parse(item);
const roundTripped: unknown = JSON.parse(JSON.stringify(parsed));
expect(LayoutItemSchema.parse(roundTripped)).toEqual(item);
}
});

it('parses correctly when sourcePath is omitted, matching every other optional field', () => {
for (const item of [text, imageItem, rect, line, ellipse, path, link]) {
const parsed = LayoutItemSchema.parse(item);
expect(parsed.sourcePath).toBeUndefined();
}
});
});

function layoutDocument(): LayoutDocument {
return {
formatVersion: LAYOUT_FORMAT_VERSION,
metadata: {
title: 'Layout round trip',
author: 'pdf-codec',
subject: 'testing',
keywords: ['layout', 'pdf'],
creator: 'pdf-codec tests',
producer: 'pdf-codec tests', // producer is normally PDF-only; exercised here as a plain optional field
createdIso: '2026-07-30T00:00:00.000Z',
modifiedIso: '2026-07-30T01:00:00.000Z',
},
pages: [
{
widthPt: 612,
heightPt: 792,
items: [text, imageItem, rect, line, ellipse, path, link],
notes: 'Speaker notes carried as a hidden annotation.',
},
{
widthPt: 612,
heightPt: 792,
items: [text],
// deliberately no `notes` field, exercising the page-without-notes case
},
],
images: {
logo: { format: 'png', base64: 'AA==', widthPx: 32, heightPx: 32 },
photo: { format: 'jpeg', base64: '/9k=', widthPx: 1024, heightPx: 768 },
},
};
}

describe('LayoutDocumentSchema round trips', () => {
it('deep-equals the original document after a JSON round trip, covering a page with notes and a page without', () => {
const original = layoutDocument();
const parsed = LayoutDocumentSchema.parse(original);
const roundTripped: unknown = JSON.parse(JSON.stringify(parsed));
expect(LayoutDocumentSchema.parse(roundTripped)).toEqual(original);
});

it('accepts a minimal document with an empty page and empty image registry', () => {
const doc: LayoutDocument = {
formatVersion: LAYOUT_FORMAT_VERSION,
metadata: {},
pages: [{ widthPt: 612, heightPt: 792, items: [] }],
images: {},
};
expect(LayoutDocumentSchema.parse(doc)).toEqual(doc);
});

it('rejects a mismatched formatVersion', () => {
expect(
LayoutDocumentSchema.safeParse({ formatVersion: 2, metadata: {}, pages: [], images: {} }).success,
).toBe(false);
});
});
Loading
Loading