Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
Agents
particle-academy/fine-print
claude-code
cursor
security
pdf
document
ai
pdf-parser
text-extraction
pdf-renderer
agentic
prompt-injection
particle-academy

This file describes this repository's code: its API, its invariants, and the traps in it. Process rules (release lifecycle, publishing, version policy, backports) live in the envelope's RULES.md and must never be copied here. A copy on a maintenance branch would freeze a rule that has since...

Install
php artisan boost:add-skill particle-academy/fine-print

Save this content to: AGENTS.md

# AGENTS.md — fine-print (PHP)

This file describes **this repository's code**: its API, its invariants, and the
traps in it. Process rules (release lifecycle, publishing, version policy,
backports) live in the envelope's `RULES.md` and must never be copied here. A
copy on a maintenance branch would freeze a rule that has since changed, with
nothing to flag it.

## What this is

A PDF reader and writer in pure PHP, with **no runtime dependency beyond PHP's bundled
`zlib`, `mbstring`, `ctype` and `json`**. That is an owner ruling, not a
preference: no GD, Imagick, Ghostscript, poppler, MuPDF, smalot/pdfparser,
FPDF, TCPDF or dompdf, and no shelling out to a binary. Needing anything else
is an approval request to the owner. It is never a `composer require`.

P1 is the reader plus the security layer; P2 is the writer (`src/Writer`);
P3a is the renderer core (`src/Render`: vector paths to PNG); P3b draws
TrueType text (`src/Render/Glyphs`); P3c adds sampled/JPEG images; P3d adds
clipping, alpha and tint functions. CFF / Type 1 / Type 3 fonts, advanced
transparency remain later. P4 serves authenticated cached/watermarked page
images through an optional Laravel 13 adapter. See the envelope plan.

| layer | where | trusts its input? |
|---|---|---|
| `Agent` | `src/Agent.php` | no. This is the only surface an agent may be given |
| `AgentReadGuard` | `src/Security/` | no |
| `ActiveContentScanner` | `src/Security/` | no |
| `Document` | `src/Document.php` | no. It is the HOST's raw view, unguarded |
| parser, filters, fonts | `src/Parser`, `src/Filters`, `src/Text` | no |
| writer: model, layout, PDF syntax | `src/Writer` | no. The document comes from an agent, which may have copied it from a hostile file |
| TrueType reader, PNG/JPEG readers | `src/Writer/Fonts`, `src/Writer/Images` | no. Host fonts and image data URLs may be user uploads |
| renderer: interpreter, rasteriser, stroker, PNG | `src/Render` | no. It runs every operator of a hostile page |

## The renderer (P3a)

`Renderer::render()` → page box (CropBox ∩ MediaBox, `/Rotate`) and the base
matrix → `Interpreter` (content operators, `GraphicsState`, `Path` in user
space, `ColourSpace`) → fills through `Rasterizer`, strokes through `Stroker`
(user space, so a non-uniform CTM gives an elliptical pen) then `Rasterizer`
→ `Raster` (one RGB byte string per row) → `PngEncoder`.

Renderer invariants:

- **Judge the pixel count before allocating.** `maxRenderPixels` is checked on
  floats, before any int cast or `Raster`. A MediaBox of 10^7 points must cost
  nothing.
- **Every operator counts**, page and forms together, against `maxOperators`;
  forms carry a visited stack and the `maxFormDepth` cap; the `q` stack is
  capped (overflow is counted, not stored). The render has its OWN `Budget`
  (`renderTimeSeconds`), separate from the document's.
- **Everything not drawn is reported in `skipped`.** Adding a kind means adding
  it to `Rendering::KINDS` and the README. Never draw less silently: a page
  image missing content that claims to be complete is the failure this exists
  to prevent.
- **Work per edge is bounded by the canvas, not by coordinates.** The
  rasteriser clips rows to the canvas and projects anything left of it onto
  x = 0; Bézier flattening is capped at `Bezier::MAX_SEGMENTS`; a dash pattern
  that would make more than `Stroker::MAX_DASHES` dashes is drawn solid. Every
  operand passes `Interpreter::numbers()`, which turns INF/NAN into 0.
- **Stroke polygons are all emitted with one orientation.** Nonzero filling
  then unions them; mixed orientations would cancel where a join overlaps a
  segment.

## Text (P3b)

`Tf` → `Glyphs\RenderFont::load()` (once per font object per render; at most
256 fonts / 64 MB of programs, then the cache starts over) → per show
operator, `RenderFont::glyphs()` yields [gid, width, is-space] → each glyph's
`Glyphs\TrueTypeGlyphs::outline()` (font units, cached) is mapped glyph →
text (1/unitsPerEm) → `[Tfs*Th 0 0 Tfs 0 Ts] x Tm` → CTM, culled against
the canvas, filled nonzero glyph by glyph, stroked through the P3a stroker.

- **Reuse, don't duplicate.** The font program is read by P2's
  `Writer\Fonts\TrueType` (`parse(..., requireUnicodeCmap: false)`,
  `cmapGlyph()`, `glyphData()`, `leftSideBearing()`); widths, `/Differences`
  and code splitting come from P1's `Text\Font` and `Text\CMap` (which now
  also read `cidrange`/`cidchar`).
- **Glyphs stream.** `glyphs()` and `Font::codes()` are generators. A show
  string can hold millions of codes; building them into a list once took a
  3-million-glyph `Tj` past PHP's memory limit (a fatal error). Never turn
  them back into arrays. Every glyph ticks the render budget.
- **Hinting is never executed.** Glyph instructions are skipped by length;
  `fpgm`, `prep` and `cvt ` are not read. Outlines are drawn unhinted.
- **Glyph limits** (`TrueTypeGlyphs::MAX_*`): 1024 contours, 16384 points
  (composites multiplied out), 128 components, nesting 8, plus a self-
  containment check. A glyph over a limit or malformed is a `FontException`,
  counted as `text-glyph`, remembered (refused once, not re-parsed), and the
  rest of the text is drawn.
- **Placement follows FreeType**: the outline moves by `lsb - xMin` (phantom
  point pp1) at the top level only. Measured in Chromium before it was
  written.
- **Text origins snap DOWN to the next whole device pixel** (owner ruling
  2026-10-22). After glyph-to-device mapping, y becomes `ceil(y)`; an aligned
  origin stays put. This is a deliberate offset from exact PDF geometry at
  every scale, including rotated text. X and advances stay exact. Fill and
  stroke move together; stroke translates its device matrix so its user-space
  pen still transforms correctly. Do not replace this with a constant shift.
- **Font names in reports** are BaseFont stripped to `[A-Za-z0-9+_.-]`, at
  most 63 characters, at most 16 per kind (`RenderFont::cleanName()`).
- **Non-embedded standard-14 text draws from bundled fonts**
  (`Glyphs\StandardFonts`, owner ruling 2026-10-21): Helvetica/Arial from
  Liberation Sans 2.1.5, Times/Times New Roman from Tinos, Courier/Courier New
  from Cousine, in `resources/fonts`, UNMODIFIED, their SHA-256 pinned in
  `NOTICE` by a test. Chosen by measured WinAnsi widths against
  `StandardMetrics` (Arimo differs on florin, so the sans is Liberation).
  Only simple fonts (Type1/TrueType/MMType1) whose name is one of those faces
  plus style words are substituted; Narrow, Black, Neue, Symbol,
  ZapfDingbats and anything else stay `text-unembedded`. Without `/Widths`,
  advances come from the standard widths by character, not from the bundled
  font. Never subset, rename or edit a bundled file; replacing one means a
  new NOTICE entry and hash.

## Images (P3c)

`Images\SampledImage` keeps packed samples, at 1/2/4/8/16 bits, with row
padding, Decode arrays, Gray/RGB/CMYK, Indexed palettes and stencil masks.
`ImagePainter` inverse-maps device pixel centres into the image unit square
(nearest neighbour), so transforms do not allocate an intermediate canvas.
`maxImagePixels` is checked on floats before decoding; decoded sample size
is checked before integer multiplication. Filters and predictors use the
render budget, not the document's older reading budget. Predictor rows are
byte strings, never arrays per byte; TIFF differs by sample, not by byte.
Unsupported/damaged images paint a checkerboard and are reported as `image`
or `inline-image`. Gray soft masks, explicit stencils and colour-key masks
are supported; nested masks and `/Matte` preblending remain reported placeholders.

`InlineImageReader` expands only inline aliases and finds raw sample lengths
or encoded stream boundaries (Flate, LZW EOD, ASCII terminators, RunLength
EOD, JPEG EOI outside marker payloads). It consumes only the ID separator,
never trims samples. Dimensions are checked BEFORE probing a compressed
boundary. Flate probing discards output chunk by chunk and checks expansion.

`JpegDecoder` is ours: baseline 8-bit Huffman sequential, DC prediction,
AC runs/zigzag, dequantisation and separable IDCT; interleaved/subsampled or
separate component scans, restart markers and stuffed bytes. Component planes
are byte strings; both their pixel dimensions and padded byte counts are
checked before allocation; loops tick the render budget. Metadata is skipped,
never exposed. Adobe APP14 overrides PDF ColorTransform (ISO 32000-1 table 13);
YCbCr/YCCK conversion happens before the PDF Decode array. CMYK inversion is
the Decode array's job, as P2 writes it. Progressive/arithmetic/lossless JPEG,
JPX, JBIG2 and CCITT are reported placeholders, not silently complete.

## Clipping, transparency and tint functions (P3d)

- `CoverageMask` is immutable byte coverage, shared by cloned graphics states.
  W/W* is deferred until the path is painted or discarded. Form BBox clips
  locally; text outlines union through a BT/ET pair and intersect at ET.
- Every vector/image paint multiplies coverage by the appropriate CA/ca.
  Soft masks sample normalised coordinates at their own resolution; explicit
  stencils preserve zero-bit opacity; colour keys compare BEFORE Decode.
- `TintFunction` handles type 2 exponentials and type 0 multilinear samples
  (up to four inputs/outputs, every PDF sample depth). Size is judged before
  decoding, interpolation work is weighted into the render budget. Calculator
  functions and cubic sampled interpolation remain reported approximations.
- `GroupSurface` stores associated native colour, alpha and shape in packed
  floats, excluding its borrowed initial backdrop (ISO11.4). Group opacity,
  BM and SMask apply ONCE at invocation; those three reset inside, AIS does not.
  Knockout uses shape, including zero-opacity objects, and a nested nonisolated
  group in a knockout parent inherits the parent's INITIAL backdrop.
- Group/mask buffers crop to a transformed, canvas-clipped rectangle before
  allocation, and share `Budget::reservePixels` with the page. Saved masks
  retain their reservation until the last graphics-state reference is gone.
  Every new plane reserves physical memory-limit headroom and row overhead.
- ExtGState masks freeze their CTM at `gs`, derive Alpha or device Luminosity,
  honour BC and bounded type0/2/3 or Identity TR. The defined value outside
  the group (including TR(0) or TR(lum(BC))) survives sparse storage. Image
  Mask/SMask overrides the graphics-state mask. TK=false text, calibrated
  luminosity masks, calculator transfers and unknown modes/spaces stay skipped.
- All16 blend modes operate in native group components; CMYK complements its
  components before blending. Native K is preserved. Page alpha is tracked
  before the white display background; combined fill/stroke is one object.
- Plain pages have RGB rows only. A non-Normal paint triggers one replay with
  page alpha, releasing the first raster/interpreter first. Both passes share
  one Budget; discarded prefix operators still count against the cap. Normal
  spans convert colour once per paint and coalesce only identical adjacent
  coverage. One opaque-row cache requires equality of the WHOLE input row;
  cached output is shared, so subsequent writes must preflight copy-on-write.
- Memory preflights share one clock/memory snapshot. The parsed PHP limit is
  cached only while its raw ini value matches. Long raster edges preflight
  sparse row hash capacity before growth and tick by touched cells; very tall
  sparse rasters preflight outer table growth too.
- Chromium's group fixture collapses all four I/K combinations to the same
  isolated/nonknockout output. GoldenTest records the full discrepancy and
  compares its supported quadrant with the ORIGINAL bounds. Other quadrants
  use independent hand-expanded ISO pixel values, proved red by ignoring I/K.
- `Budget::allocation` judges worst-case bytes BEFORE allocation, including
  per-row string/array overhead and PHP memory_limit headroom. Raster, clip
  masks and JPEG planes all use it. A thin/tall page can pass a pixel cap yet
  exhaust row-array memory; child-process tests reproduce both that and a
  physical PHP limit breach. Conservative capacity checks may refuse a sparse
  raster whose initial shared blank rows would have been smaller.

## Page serving (P4)

- `Laravel\PageImages::register` belongs in a service provider's boot. Routes
  store a controller + opaque binding key; never capture resolvers/cache clients
  into a serialised route. Domain/prefix scopes distinguish handler bindings.
- App middleware runs first; the resolver authorises on EVERY request before
  a cache lookup. PageSource key/version are server values. Cache keys also
  include a content hash, page, bounded quarter-step scale and limits.
- `PageSource::fromStream` reads only a caller-opened authorised resource,
  capped at maxBytes + 1 and cooperatively timed/memory-guarded; never opens
  client paths/URLs. Caller owns the resource. Its refusals become HTTP errors.
- `RasterCache` uses compressed RGB + bounded JSON metadata and a SHA-256
  corruption check. It is private cache data, not authenticated against a
  compromised cache backend. No PHP object deserialisation. Parser warnings
  are not cached; stable render metadata/pixels are preserved. A corrupt packet
  is a cache miss; a Budget refusal must propagate.
- `Watermark` always copies the clean raster. Names/email/time are per request,
  bounded to three lines/96 WinAnsi characters, hex-encoded before interpretation.
  Source pixels/omission flags survive. Required stamp failures cannot become
  an unwatermarked success. WinAnsiEncoding is explicit (Euro pixel reference).
- One three-second rendering budget spans cache restore/render/stamp/PNG; cold
  render stages inherit remaining time/memory. App/source/backend I/O outside
  those cooperative operations remains the host's timeout responsibility.
- Incomplete pages refuse by default; explicit host opt-in preserves the
  complete flag header. Never return PDF bytes or attacker exception text.
- Test-only Illuminate 13 components are the RULES.md framework carve-out;
  `StandaloneTest` proves core use without vendor/autoload.php. Do not let
  framework globals become accidental core runtime dependencies.

## Encryption (owner ruling 2026-10-21: open empty-password files)

`Security\StandardSecurity` authenticates with the EMPTY user password only
and derives the key; `Crypto\Rc4` and `Crypto\Aes` are ours (no openssl).
`Document::secure()` runs right after the xref is read and BEFORE any object is
resolved, then clears the cache, so nothing is ever cached still encrypted.
`parseAt()` decrypts every object read from the file except the `/Encrypt`
dictionary itself; cross-reference streams, objects inside object streams
(their container was decrypted), a signature's `/Contents` and, under
`EncryptMetadata false`, the metadata stream stay as they are.

- Needs a password, another handler, or an unknown V/R/CFM: `encrypted`.
  Malformed (types, lengths, key length, a missing crypt filter): `corrupt`.
- Never try any password but "". Never put the key, `/O`, `/U` or anything
  derived from them in a report or a warning.
- Permissions are reported (`Document::encryption()`), never enforced here.
- A damaged AES string or stream decrypts to empty with a warning.

## The writer

`Validator` / `Repairer` / `Schema` (last-word's model) → `Layout\Composer`
(model to flowables: `ParagraphFlow`, `TableFlow`, `ContainerFlow`,
`ImageFlow`, `RuleFlow`, `PageBreakFlow`) → `Layout\Paginator` (pages of
`Canvas`) → `PdfWriter` (objects, via `Pdf\PdfFile` and `Pdf\Syntax`).
`Linter` writes, then reads the result back through `Agent::read()`.

Writer invariants:

- **Nothing active exists to be emitted.** The writer writes a catalog, a page
  tree, pages, content streams, fonts, images, `/Link` annotations with a
  `/URI` action, and an Info dictionary. Adding any other object type is a
  security change, and `WriterSecurityTest` searches the bytes for every
  forbidden name.
- **Links are checked twice** (`Composer::style()` and `PdfWriter`), against
  `Schema::linkAllowed()`. Keep both.
- **Model text is only ever written as a hex string** (`Syntax::hex/text`).
  Never write a literal `( )` string from model data.
- **Names come from constants or are sanitised.** A TrueType PostScript name is
  stripped to `[A-Za-z0-9_.-]` before it becomes `/BaseFont`.
- **Deterministic.** No dates, no randomness, no hash-ordered iteration of
  anything that reaches the output. The golden test pins
  `tests/fixtures/canonical.json` uncompressed.
- **Images are bounded**: `Image::MAX_PIXELS`, `MAX_BYTES`, and every PNG is
  inflated once at parse to prove it is exactly what IHDR promises.

## Exchange (portable content, exports)

`src/Exchange`: `Content` (validation, limits, `truncateCell`, canonical
strings), `FromPortable` (content to blocks), `ToPortable` (a guarded read to
content), `Portable` (expands inline `portable` blocks), `Report` (the closed
kind list, merge), `ExportPolicy` (default deny). Normative text and tests:
fancy-conformance `suites/exchange/SPEC.md`, run by
`tests/Unit/Exchange/ConformanceTest.php` against an EXACT require-dev pin.

- **Portable content is hostile.** Every object is closed; the FIRST failure
  is reported in SPEC section 7's order. Grids are intervals, never enumerated
  slots, so a span of 10^9 costs nothing; image size comes from the base64
  length and only the head is decoded for the magic check.
- **Both JSON decodings are accepted** (arrays and stdClass). Only stdClass
  tells `{}` from `[]`, so the conformance runner feeds rows as objects.
- **`$defs.block` is the model; `portable` is an input** referenced beside
  it (`$defs.blockOrPortable`). It never survives validateAndRepair or reaches
  the writer. `doc-model/shared` reads `$defs.block`; keep it model-only.
- **Invalid portable content is refused, never repaired.** Repairer leaves a
  `portable` block alone; validateAndRepair returns `ok: false`.
- **Reports carry no text.** `where` is built from indexes and fixed keys;
  extract counts the read's findings by rule and drops excerpt and location.
- **Exports are off by default.** Only a host `ExportPolicy` enables one;
  agent options are `selector`, `headerRows`, `display`, anything else throws.
- extract strips `AgentReadGuard::INVISIBLE` (one definition) under every
  guard policy.

## The trust boundary is the whole package

Every byte of a PDF is attacker-authored, and every function here is reachable
by a consumer through a file an end user uploaded. **There is no "internal"
code in this repo for trust purposes.** A CMap parser, a predictor and a
`/Differences` loop are all handling hostile input. Judge a change by "what
does a hostile file get out of this", never by which directory it is in.

## Invariants

- **Nothing executes, follows or fetches.** No code path runs a script, opens
  an embedded file, resolves a URI, follows `/GoToR` or `/Launch`, or submits a
  form. `ActiveContentScanner` reports these and must never gain the ability
  to act on one.
- **No report carries attacker text by default.** Active-content findings
  identify scripts by length + SHA-256 and never include the body, launch
  target or URI. Guard findings carry a location and include an excerpt only
  when the host passes `excerpts: true`. A test pins each with
  `json_encode(...)->not->toContain(...)`. Keep it that way: an excerpt of an
  injection handed to an agent IS the injection.
- **Names are compared decoded.** `Parser::parseName()` decodes `#xx` before
  anything sees the name, which is why `/J#61vaScript` is caught. Never
  compare raw name bytes.
- **Every loop a file can lengthen is bounded.** `Budget::tick()` runs in the
  lexer, every decoder checks `Budget::decoding()` per chunk, and the
  interpreter counts operators. Anything that recurses on file structure (page
  tree, outline, forms, references, `/Prev`) carries a visited set or a depth
  cap. Add the bound in the same change as the loop.
- **Refusals are `RefusedException` with a reason from `REASONS`.** Never a
  fatal error, a hang, or a partial result presented as a whole one. Damage
  that is survived instead goes in `Document::warnings()`, and warnings never
  contain file content.
- **Show strings and page text stream; nothing lists them.** `Font::decode()`
  and `Font::codes()` are generators, the extractor ticks the budget per code
  weighted by `Font::codeCost()` (ToUnicode lookups are linear in ranges),
  and `AgentReadGuard::normalise()` walks the text byte by byte with a packed
  breakpoint map. A list per code or per character once took a 55 KB file
  past PHP's memory limit in both places (a fatal error). `Font::split()` and
  `CMap::split()` still build lists and must not be put back on a hostile
  path.
- **Decompression is judged while it runs.** Flate is fed in 4 KB chunks, so a
  bomb is stopped after a few megabytes rather than after it has expanded.
  Do not "simplify" it to `gzuncompress()`.
- **The extractor keeps invisible characters and hidden text.** Stripping them
  early would hide them from the guard. Only the guard removes anything.

## Traps

- **Do not patch a built fixture with `str_replace` unless the replacement is
  the same length.** Every offset after it moves, the xref then lies, and the
  test silently passes through the REPAIR path instead of the one it meant to
  test. Reserve object numbers with `PdfBuilder::reserve()` or use the known
  numbering from `PdfBuilder::textPdf()` (catalog 1, tree 2, font 3, then
  content + page per page).
- **`strrpos($pdf, 'xref')` finds `startxref`.** Use `strpos($pdf, "\nxref\n")`.
- The hidden-text check assumes a white page. White text is only exempt when a
  non-white fill or an image was painted under it first.
- The XMP packet header's `begin="U+FEFF"` is required by the XMP spec and is
  exempt from `invisible-chars`. Without that exemption, `discard` throws away
  nearly every real PDF (found by the LibreOffice oracle).
- **LibreOffice is a poor judge of text positions.** Its PDF import re-flows
  each line with its own spacing: a correctly justified line renders ragged,
  and an underline drawn where layout put the words looks adrift. Chromium's
  viewer (PDFium) renders the same bytes exactly. Do not "fix" positioning to
  please LibreOffice. Its Draw PNG export also ignores `PageRange` and always
  renders page 1.
- **PHP's `range(0, -1)` is `[0, -1]`**, not `[]`. `TableFlow` builds its
  header-row list explicitly for that reason.
- `StandardMetrics` is generated data. Regenerate it with the envelope's
  `sandbox/fine-print/p2/metrics.php` + `emit-metrics.php`, never by hand.
- **Chromium's PDF viewer is an exact oracle only at some geometries**
  (measured 2026-10-21, Chromium 156). At zoom 150 with device scale 1, one
  point is exactly 2 px, but only for some page sizes: 200x150 pt comes out
  0.34% taller and 0.06 px off the pixel grid. At zoom 75 a 200 pt page is
  drawn 201 px wide. Golden fixtures use measured-exact sizes, scale-1 goldens
  are the zoom-150 render averaged 2x2, and `tests/oracle/crop.php` refuses an
  off-grid page. Calibrate with anti-aliased TRIANGLES, never bars.
- **PDFium snaps axis-aligned rectangle fills to whole pixels** (no
  anti-aliasing on their edges, by its own rounding). We anti-alias them by
  exact coverage. That difference is most of the remaining error in the
  `rects` goldens. Do not "fix" it by copying PDFium's rounding without
  deciding it is wanted.
- **PDFium spaces zero-length dashes slightly apart** (`[0 12]` gives dots
  every 12.1 pt), so a long dotted line drifts against Chromium. Ours are at
  exact multiples, which is what the spec says.
- **Chromium is a coarse oracle for TEXT** (measured 2026-10-21, Chromium
  156). It snaps every glyph origin to a whole device pixel; its glyphs bleed
  past their outlines (about 0.3 px right and top at its 2x, ~0 left and
  bottom); and FreeType runs TrueType hinting, which moves a hinted font's
  stems and baselines by up to half a pixel. Against it a 2x text golden
  cannot see a one-pixel error. So: the `glyphs` golden keeps glyph origins
  on half points (whole pixels at the oracle's 2x) and uses an unhinted
  font, and exact placement is pinned by `TextRenderTest`'s pixel tests. Do
  not add x snapping, bleed or hinting to please Chromium. Downward y snapping
  is the deliberate placement policy above. Turning off PDFium's Skia renderer
  changes nothing.
- **A test file must run on its own.** Shared text helpers live in
  `tests/Support/text-helpers.php` (loaded by `tests/Pest.php`). When they
  lived in `TextRenderTest.php`, `TextHostileTest` passed in the full suite
  and errored alone, and three sabotage runs read that error as "red".

## Tests

Pest, test-first. **Every PDF a test reads is written by `tests/Support`**
(`PdfBuilder`, `Encoders`) **or by the writer itself**, and every font and
image by `FontBuilder` / `ImageBuilder`. No downloaded files and no
third-party producer. The bundled `resources/fonts` are read as the package
reads them (`StandardFontsTest`, the `std14` golden). The other exception is the `local` group, which reads
`C:/Windows/Fonts/DejaVuSans.ttf` when it exists and skips otherwise.
Each security behaviour has a test that was red before it existed, and
`tests/Unit/HostileInputTest.php` holds one fixture per attack. `composer test`
runs the suite.

Renderer goldens: `tests/golden/<name>.pdf` (written by
`tests/Support/RenderFixtures`) and `<name>@<scale>x.png` (Chromium's
rendering of it). `GoldenTest` rebuilds each PDF and fails if it no longer
matches the committed one, then compares with `tests/Support/ImageDiff` (mean
absolute error and share of pixels off by more than 64, a 2 px frame
excluded); `tests/Support/PngReader` is a PNG decoder written separately from
our encoder. Numbers and diff images land in `tests/.out/`. To regenerate,
run the three scripts in `tests/oracle/` (Chromium + playwright-core are a
TEST ORACLE on the developer's machine, never a dependency); `ONLY=a,b`
limits `build.php` to some fixtures so the others' goldens are untouched.
**The oracle never shows a window.** `chromium.cjs` launches only through
Playwright, headless, with `--no-first-run`, Playwright's throwaway profile
and a `finally` close, and refuses to start otherwise. Never run `chrome.exe`
directly, not even `--version`: on Windows that opens a visible window with
first-run onboarding on the owner's desktop.

Text goldens: `glyphs` (written by `RenderFixtures`, snap-immune, strict
bounds) and `p2-text` (written by the P2 writer) embed
`FontBuilder::outlined()`, a font drawn in our own code. `p2-dejavu` is in
the `local` group: it is built at test time from the machine's
`DejaVuSans.ttf`, so **no font byte is committed**, only Chromium's PNG and
the PDF's SHA-256 (`p2-dejavu.pdf.sha256`); a different font version skips.
The text bounds were revised after the first run; `GoldenTest` says why, and
its "wrong on purpose" controls must keep failing.

AGENTS.md — fine-print (PHP)

This file describes this repository's code: its API, its invariants, and the traps in it. Process rules (release lifecycle, publishing, version policy, backports) live in the envelope's RULES.md and must never be copied here. A copy on a maintenance branch would freeze a rule that has since changed, with nothing to flag it.

What this is

A PDF reader and writer in pure PHP, with no runtime dependency beyond PHP's bundled zlib, mbstring, ctype and json. That is an owner ruling, not a preference: no GD, Imagick, Ghostscript, poppler, MuPDF, smalot/pdfparser, FPDF, TCPDF or dompdf, and no shelling out to a binary. Needing anything else is an approval request to the owner. It is never a composer require.

P1 is the reader plus the security layer; P2 is the writer (src/Writer); P3a is the renderer core (src/Render: vector paths to PNG); P3b draws TrueType text (src/Render/Glyphs); P3c adds sampled/JPEG images; P3d adds clipping, alpha and tint functions. CFF / Type 1 / Type 3 fonts, advanced transparency remain later. P4 serves authenticated cached/watermarked page images through an optional Laravel 13 adapter. See the envelope plan.

layer where trusts its input?
Agent src/Agent.php no. This is the only surface an agent may be given
AgentReadGuard src/Security/ no
ActiveContentScanner src/Security/ no
Document src/Document.php no. It is the HOST's raw view, unguarded
parser, filters, fonts src/Parser, src/Filters, src/Text no
writer: model, layout, PDF syntax src/Writer no. The document comes from an agent, which may have copied it from a hostile file
TrueType reader, PNG/JPEG readers src/Writer/Fonts, src/Writer/Images no. Host fonts and image data URLs may be user uploads
renderer: interpreter, rasteriser, stroker, PNG src/Render no. It runs every operator of a hostile page

The renderer (P3a)

Renderer::render() → page box (CropBox ∩ MediaBox, /Rotate) and the base matrix → Interpreter (content operators, GraphicsState, Path in user space, ColourSpace) → fills through Rasterizer, strokes through Stroker (user space, so a non-uniform CTM gives an elliptical pen) then Rasterizer → Raster (one RGB byte string per row) → PngEncoder.

Renderer invariants:

  • Judge the pixel count before allocating. maxRenderPixels is checked on floats, before any int cast or Raster. A MediaBox of 10^7 points must cost nothing.
  • Every operator counts, page and forms together, against maxOperators; forms carry a visited stack and the maxFormDepth cap; the q stack is capped (overflow is counted, not stored). The render has its OWN Budget (renderTimeSeconds), separate from the document's.
  • Everything not drawn is reported in skipped. Adding a kind means adding it to Rendering::KINDS and the README. Never draw less silently: a page image missing content that claims to be complete is the failure this exists to prevent.
  • Work per edge is bounded by the canvas, not by coordinates. The rasteriser clips rows to the canvas and projects anything left of it onto x = 0; Bézier flattening is capped at Bezier::MAX_SEGMENTS; a dash pattern that would make more than Stroker::MAX_DASHES dashes is drawn solid. Every operand passes Interpreter::numbers(), which turns INF/NAN into 0.
  • Stroke polygons are all emitted with one orientation. Nonzero filling then unions them; mixed orientations would cancel where a join overlaps a segment.

Text (P3b)

Tf → Glyphs\RenderFont::load() (once per font object per render; at most 256 fonts / 64 MB of programs, then the cache starts over) → per show operator, RenderFont::glyphs() yields [gid, width, is-space] → each glyph's Glyphs\TrueTypeGlyphs::outline() (font units, cached) is mapped glyph → text (1/unitsPerEm) → [Tfs*Th 0 0 Tfs 0 Ts] x Tm → CTM, culled against the canvas, filled nonzero glyph by glyph, stroked through the P3a stroker.

  • Reuse, don't duplicate. The font program is read by P2's Writer\Fonts\TrueType (parse(..., requireUnicodeCmap: false), cmapGlyph(), glyphData(), leftSideBearing()); widths, /Differences and code splitting come from P1's Text\Font and Text\CMap (which now also read cidrange/cidchar).
  • Glyphs stream. glyphs() and Font::codes() are generators. A show string can hold millions of codes; building them into a list once took a 3-million-glyph Tj past PHP's memory limit (a fatal error). Never turn them back into arrays. Every glyph ticks the render budget.
  • Hinting is never executed. Glyph instructions are skipped by length; fpgm, prep and cvt are not read. Outlines are drawn unhinted.
  • Glyph limits (TrueTypeGlyphs::MAX_*): 1024 contours, 16384 points (composites multiplied out), 128 components, nesting 8, plus a self- containment check. A glyph over a limit or malformed is a FontException, counted as text-glyph, remembered (refused once, not re-parsed), and the rest of the text is drawn.
  • Placement follows FreeType: the outline moves by lsb - xMin (phantom point pp1) at the top level only. Measured in Chromium before it was written.
  • Text origins snap DOWN to the next whole device pixel (owner ruling 2026-10-22). After glyph-to-device mapping, y becomes ceil(y); an aligned origin stays put. This is a deliberate offset from exact PDF geometry at every scale, including rotated text. X and advances stay exact. Fill and stroke move together; stroke translates its device matrix so its user-space pen still transforms correctly. Do not replace this with a constant shift.
  • Font names in reports are BaseFont stripped to [A-Za-z0-9+_.-], at most 63 characters, at most 16 per kind (RenderFont::cleanName()).
  • Non-embedded standard-14 text draws from bundled fonts (Glyphs\StandardFonts, owner ruling 2026-10-21): Helvetica/Arial from Liberation Sans 2.1.5, Times/Times New Roman from Tinos, Courier/Courier New from Cousine, in resources/fonts, UNMODIFIED, their SHA-256 pinned in NOTICE by a test. Chosen by measured WinAnsi widths against StandardMetrics (Arimo differs on florin, so the sans is Liberation). Only simple fonts (Type1/TrueType/MMType1) whose name is one of those faces plus style words are substituted; Narrow, Black, Neue, Symbol, ZapfDingbats and anything else stay text-unembedded. Without /Widths, advances come from the standard widths by character, not from the bundled font. Never subset, rename or edit a bundled file; replacing one means a new NOTICE entry and hash.

Images (P3c)

Images\SampledImage keeps packed samples, at 1/2/4/8/16 bits, with row padding, Decode arrays, Gray/RGB/CMYK, Indexed palettes and stencil masks. ImagePainter inverse-maps device pixel centres into the image unit square (nearest neighbour), so transforms do not allocate an intermediate canvas. maxImagePixels is checked on floats before decoding; decoded sample size is checked before integer multiplication. Filters and predictors use the render budget, not the document's older reading budget. Predictor rows are byte strings, never arrays per byte; TIFF differs by sample, not by byte. Unsupported/damaged images paint a checkerboard and are reported as image or inline-image. Gray soft masks, explicit stencils and colour-key masks are supported; nested masks and /Matte preblending remain reported placeholders.

InlineImageReader expands only inline aliases and finds raw sample lengths or encoded stream boundaries (Flate, LZW EOD, ASCII terminators, RunLength EOD, JPEG EOI outside marker payloads). It consumes only the ID separator, never trims samples. Dimensions are checked BEFORE probing a compressed boundary. Flate probing discards output chunk by chunk and checks expansion.

JpegDecoder is ours: baseline 8-bit Huffman sequential, DC prediction, AC runs/zigzag, dequantisation and separable IDCT; interleaved/subsampled or separate component scans, restart markers and stuffed bytes. Component planes are byte strings; both their pixel dimensions and padded byte counts are checked before allocation; loops tick the render budget. Metadata is skipped, never exposed. Adobe APP14 overrides PDF ColorTransform (ISO 32000-1 table 13); YCbCr/YCCK conversion happens before the PDF Decode array. CMYK inversion is the Decode array's job, as P2 writes it. Progressive/arithmetic/lossless JPEG, JPX, JBIG2 and CCITT are reported placeholders, not silently complete.

Clipping, transparency and tint functions (P3d)

  • CoverageMask is immutable byte coverage, shared by cloned graphics states. W/W* is deferred until the path is painted or discarded. Form BBox clips locally; text outlines union through a BT/ET pair and intersect at ET.
  • Every vector/image paint multiplies coverage by the appropriate CA/ca. Soft masks sample normalised coordinates at their own resolution; explicit stencils preserve zero-bit opacity; colour keys compare BEFORE Decode.
  • TintFunction handles type 2 exponentials and type 0 multilinear samples (up to four inputs/outputs, every PDF sample depth). Size is judged before decoding, interpolation work is weighted into the render budget. Calculator functions and cubic sampled interpolation remain reported approximations.
  • GroupSurface stores associated native colour, alpha and shape in packed floats, excluding its borrowed initial backdrop (ISO11.4). Group opacity, BM and SMask apply ONCE at invocation; those three reset inside, AIS does not. Knockout uses shape, including zero-opacity objects, and a nested nonisolated group in a knockout parent inherits the parent's INITIAL backdrop.
  • Group/mask buffers crop to a transformed, canvas-clipped rectangle before allocation, and share Budget::reservePixels with the page. Saved masks retain their reservation until the last graphics-state reference is gone. Every new plane reserves physical memory-limit headroom and row overhead.
  • ExtGState masks freeze their CTM at gs, derive Alpha or device Luminosity, honour BC and bounded type0/2/3 or Identity TR. The defined value outside the group (including TR(0) or TR(lum(BC))) survives sparse storage. Image Mask/SMask overrides the graphics-state mask. TK=false text, calibrated luminosity masks, calculator transfers and unknown modes/spaces stay skipped.
  • All16 blend modes operate in native group components; CMYK complements its components before blending. Native K is preserved. Page alpha is tracked before the white display background; combined fill/stroke is one object.
  • Plain pages have RGB rows only. A non-Normal paint triggers one replay with page alpha, releasing the first raster/interpreter first. Both passes share one Budget; discarded prefix operators still count against the cap. Normal spans convert colour once per paint and coalesce only identical adjacent coverage. One opaque-row cache requires equality of the WHOLE input row; cached output is shared, so subsequent writes must preflight copy-on-write.
  • Memory preflights share one clock/memory snapshot. The parsed PHP limit is cached only while its raw ini value matches. Long raster edges preflight sparse row hash capacity before growth and tick by touched cells; very tall sparse rasters preflight outer table growth too.
  • Chromium's group fixture collapses all four I/K combinations to the same isolated/nonknockout output. GoldenTest records the full discrepancy and compares its supported quadrant with the ORIGINAL bounds. Other quadrants use independent hand-expanded ISO pixel values, proved red by ignoring I/K.
  • Budget::allocation judges worst-case bytes BEFORE allocation, including per-row string/array overhead and PHP memory_limit headroom. Raster, clip masks and JPEG planes all use it. A thin/tall page can pass a pixel cap yet exhaust row-array memory; child-process tests reproduce both that and a physical PHP limit breach. Conservative capacity checks may refuse a sparse raster whose initial shared blank rows would have been smaller.

Page serving (P4)

  • Laravel\PageImages::register belongs in a service provider's boot. Routes store a controller + opaque binding key; never capture resolvers/cache clients into a serialised route. Domain/prefix scopes distinguish handler bindings.
  • App middleware runs first; the resolver authorises on EVERY request before a cache lookup. PageSource key/version are server values. Cache keys also include a content hash, page, bounded quarter-step scale and limits.
  • PageSource::fromStream reads only a caller-opened authorised resource, capped at maxBytes + 1 and cooperatively timed/memory-guarded; never opens client paths/URLs. Caller owns the resource. Its refusals become HTTP errors.
  • RasterCache uses compressed RGB + bounded JSON metadata and a SHA-256 corruption check. It is private cache data, not authenticated against a compromised cache backend. No PHP object deserialisation. Parser warnings are not cached; stable render metadata/pixels are preserved. A corrupt packet is a cache miss; a Budget refusal must propagate.
  • Watermark always copies the clean raster. Names/email/time are per request, bounded to three lines/96 WinAnsi characters, hex-encoded before interpretation. Source pixels/omission flags survive. Required stamp failures cannot become an unwatermarked success. WinAnsiEncoding is explicit (Euro pixel reference).
  • One three-second rendering budget spans cache restore/render/stamp/PNG; cold render stages inherit remaining time/memory. App/source/backend I/O outside those cooperative operations remains the host's timeout responsibility.
  • Incomplete pages refuse by default; explicit host opt-in preserves the complete flag header. Never return PDF bytes or attacker exception text.
  • Test-only Illuminate 13 components are the RULES.md framework carve-out; StandaloneTest proves core use without vendor/autoload.php. Do not let framework globals become accidental core runtime dependencies.

Encryption (owner ruling 2026-10-21: open empty-password files)

Security\StandardSecurity authenticates with the EMPTY user password only and derives the key; Crypto\Rc4 and Crypto\Aes are ours (no openssl). Document::secure() runs right after the xref is read and BEFORE any object is resolved, then clears the cache, so nothing is ever cached still encrypted. parseAt() decrypts every object read from the file except the /Encrypt dictionary itself; cross-reference streams, objects inside object streams (their container was decrypted), a signature's /Contents and, under EncryptMetadata false, the metadata stream stay as they are.

  • Needs a password, another handler, or an unknown V/R/CFM: encrypted. Malformed (types, lengths, key length, a missing crypt filter): corrupt.
  • Never try any password but "". Never put the key, /O, /U or anything derived from them in a report or a warning.
  • Permissions are reported (Document::encryption()), never enforced here.
  • A damaged AES string or stream decrypts to empty with a warning.

The writer

Validator / Repairer / Schema (last-word's model) → Layout\Composer (model to flowables: ParagraphFlow, TableFlow, ContainerFlow, ImageFlow, RuleFlow, PageBreakFlow) → Layout\Paginator (pages of Canvas) → PdfWriter (objects, via Pdf\PdfFile and Pdf\Syntax). Linter writes, then reads the result back through Agent::read().

Writer invariants:

  • Nothing active exists to be emitted. The writer writes a catalog, a page tree, pages, content streams, fonts, images, /Link annotations with a /URI action, and an Info dictionary. Adding any other object type is a security change, and WriterSecurityTest searches the bytes for every forbidden name.
  • Links are checked twice (Composer::style() and PdfWriter), against Schema::linkAllowed(). Keep both.
  • Model text is only ever written as a hex string (Syntax::hex/text). Never write a literal ( ) string from model data.
  • Names come from constants or are sanitised. A TrueType PostScript name is stripped to [A-Za-z0-9_.-] before it becomes /BaseFont.
  • Deterministic. No dates, no randomness, no hash-ordered iteration of anything that reaches the output. The golden test pins tests/fixtures/canonical.json uncompressed.
  • Images are bounded: Image::MAX_PIXELS, MAX_BYTES, and every PNG is inflated once at parse to prove it is exactly what IHDR promises.

Exchange (portable content, exports)

src/Exchange: Content (validation, limits, truncateCell, canonical strings), FromPortable (content to blocks), ToPortable (a guarded read to content), Portable (expands inline portable blocks), Report (the closed kind list, merge), ExportPolicy (default deny). Normative text and tests: fancy-conformance suites/exchange/SPEC.md, run by tests/Unit/Exchange/ConformanceTest.php against an EXACT require-dev pin.

  • Portable content is hostile. Every object is closed; the FIRST failure is reported in SPEC section 7's order. Grids are intervals, never enumerated slots, so a span of 10^9 costs nothing; image size comes from the base64 length and only the head is decoded for the magic check.
  • Both JSON decodings are accepted (arrays and stdClass). Only stdClass tells {} from [], so the conformance runner feeds rows as objects.
  • $defs.block is the model; portable is an input referenced beside it ($defs.blockOrPortable). It never survives validateAndRepair or reaches the writer. doc-model/shared reads $defs.block; keep it model-only.
  • Invalid portable content is refused, never repaired. Repairer leaves a portable block alone; validateAndRepair returns ok: false.
  • Reports carry no text. where is built from indexes and fixed keys; extract counts the read's findings by rule and drops excerpt and location.
  • Exports are off by default. Only a host ExportPolicy enables one; agent options are selector, headerRows, display, anything else throws.
  • extract strips AgentReadGuard::INVISIBLE (one definition) under every guard policy.

The trust boundary is the whole package

Every byte of a PDF is attacker-authored, and every function here is reachable by a consumer through a file an end user uploaded. There is no "internal" code in this repo for trust purposes. A CMap parser, a predictor and a /Differences loop are all handling hostile input. Judge a change by "what does a hostile file get out of this", never by which directory it is in.

Invariants

  • Nothing executes, follows or fetches. No code path runs a script, opens an embedded file, resolves a URI, follows /GoToR or /Launch, or submits a form. ActiveContentScanner reports these and must never gain the ability to act on one.
  • No report carries attacker text by default. Active-content findings identify scripts by length + SHA-256 and never include the body, launch target or URI. Guard findings carry a location and include an excerpt only when the host passes excerpts: true. A test pins each with json_encode(...)->not->toContain(...). Keep it that way: an excerpt of an injection handed to an agent IS the injection.
  • Names are compared decoded. Parser::parseName() decodes #xx before anything sees the name, which is why /J#61vaScript is caught. Never compare raw name bytes.
  • Every loop a file can lengthen is bounded. Budget::tick() runs in the lexer, every decoder checks Budget::decoding() per chunk, and the interpreter counts operators. Anything that recurses on file structure (page tree, outline, forms, references, /Prev) carries a visited set or a depth cap. Add the bound in the same change as the loop.
  • Refusals are RefusedException with a reason from REASONS. Never a fatal error, a hang, or a partial result presented as a whole one. Damage that is survived instead goes in Document::warnings(), and warnings never contain file content.
  • Show strings and page text stream; nothing lists them. Font::decode() and Font::codes() are generators, the extractor ticks the budget per code weighted by Font::codeCost() (ToUnicode lookups are linear in ranges), and AgentReadGuard::normalise() walks the text byte by byte with a packed breakpoint map. A list per code or per character once took a 55 KB file past PHP's memory limit in both places (a fatal error). Font::split() and CMap::split() still build lists and must not be put back on a hostile path.
  • Decompression is judged while it runs. Flate is fed in 4 KB chunks, so a bomb is stopped after a few megabytes rather than after it has expanded. Do not "simplify" it to gzuncompress().
  • The extractor keeps invisible characters and hidden text. Stripping them early would hide them from the guard. Only the guard removes anything.

Traps

  • Do not patch a built fixture with str_replace unless the replacement is the same length. Every offset after it moves, the xref then lies, and the test silently passes through the REPAIR path instead of the one it meant to test. Reserve object numbers with PdfBuilder::reserve() or use the known numbering from PdfBuilder::textPdf() (catalog 1, tree 2, font 3, then content + page per page).
  • strrpos($pdf, 'xref') finds startxref. Use strpos($pdf, "\nxref\n").
  • The hidden-text check assumes a white page. White text is only exempt when a non-white fill or an image was painted under it first.
  • The XMP packet header's begin="U+FEFF" is required by the XMP spec and is exempt from invisible-chars. Without that exemption, discard throws away nearly every real PDF (found by the LibreOffice oracle).
  • LibreOffice is a poor judge of text positions. Its PDF import re-flows each line with its own spacing: a correctly justified line renders ragged, and an underline drawn where layout put the words looks adrift. Chromium's viewer (PDFium) renders the same bytes exactly. Do not "fix" positioning to please LibreOffice. Its Draw PNG export also ignores PageRange and always renders page 1.
  • PHP's range(0, -1) is [0, -1], not []. TableFlow builds its header-row list explicitly for that reason.
  • StandardMetrics is generated data. Regenerate it with the envelope's sandbox/fine-print/p2/metrics.php + emit-metrics.php, never by hand.
  • Chromium's PDF viewer is an exact oracle only at some geometries (measured 2026-10-21, Chromium 156). At zoom 150 with device scale 1, one point is exactly 2 px, but only for some page sizes: 200x150 pt comes out 0.34% taller and 0.06 px off the pixel grid. At zoom 75 a 200 pt page is drawn 201 px wide. Golden fixtures use measured-exact sizes, scale-1 goldens are the zoom-150 render averaged 2x2, and tests/oracle/crop.php refuses an off-grid page. Calibrate with anti-aliased TRIANGLES, never bars.
  • PDFium snaps axis-aligned rectangle fills to whole pixels (no anti-aliasing on their edges, by its own rounding). We anti-alias them by exact coverage. That difference is most of the remaining error in the rects goldens. Do not "fix" it by copying PDFium's rounding without deciding it is wanted.
  • PDFium spaces zero-length dashes slightly apart ([0 12] gives dots every 12.1 pt), so a long dotted line drifts against Chromium. Ours are at exact multiples, which is what the spec says.
  • Chromium is a coarse oracle for TEXT (measured 2026-10-21, Chromium 156). It snaps every glyph origin to a whole device pixel; its glyphs bleed past their outlines (about 0.3 px right and top at its 2x, ~0 left and bottom); and FreeType runs TrueType hinting, which moves a hinted font's stems and baselines by up to half a pixel. Against it a 2x text golden cannot see a one-pixel error. So: the glyphs golden keeps glyph origins on half points (whole pixels at the oracle's 2x) and uses an unhinted font, and exact placement is pinned by TextRenderTest's pixel tests. Do not add x snapping, bleed or hinting to please Chromium. Downward y snapping is the deliberate placement policy above. Turning off PDFium's Skia renderer changes nothing.
  • A test file must run on its own. Shared text helpers live in tests/Support/text-helpers.php (loaded by tests/Pest.php). When they lived in TextRenderTest.php, TextHostileTest passed in the full suite and errored alone, and three sabotage runs read that error as "red".

Tests

Pest, test-first. Every PDF a test reads is written by tests/Support (PdfBuilder, Encoders) or by the writer itself, and every font and image by FontBuilder / ImageBuilder. No downloaded files and no third-party producer. The bundled resources/fonts are read as the package reads them (StandardFontsTest, the std14 golden). The other exception is the local group, which reads C:/Windows/Fonts/DejaVuSans.ttf when it exists and skips otherwise. Each security behaviour has a test that was red before it existed, and tests/Unit/HostileInputTest.php holds one fixture per attack. composer test runs the suite.

Renderer goldens: tests/golden/<name>.pdf (written by tests/Support/RenderFixtures) and <name>@<scale>x.png (Chromium's rendering of it). GoldenTest rebuilds each PDF and fails if it no longer matches the committed one, then compares with tests/Support/ImageDiff (mean absolute error and share of pixels off by more than 64, a 2 px frame excluded); tests/Support/PngReader is a PNG decoder written separately from our encoder. Numbers and diff images land in tests/.out/. To regenerate, run the three scripts in tests/oracle/ (Chromium + playwright-core are a TEST ORACLE on the developer's machine, never a dependency); ONLY=a,b limits build.php to some fixtures so the others' goldens are untouched. The oracle never shows a window. chromium.cjs launches only through Playwright, headless, with --no-first-run, Playwright's throwaway profile and a finally close, and refuses to start otherwise. Never run chrome.exe directly, not even --version: on Windows that opens a visible window with first-run onboarding on the owner's desktop.

Text goldens: glyphs (written by RenderFixtures, snap-immune, strict bounds) and p2-text (written by the P2 writer) embed FontBuilder::outlined(), a font drawn in our own code. p2-dejavu is in the local group: it is built at test time from the machine's DejaVuSans.ttf, so no font byte is committed, only Chromium's PNG and the PDF's SHA-256 (p2-dejavu.pdf.sha256); a different font version skips. The text bounds were revised after the first run; GoldenTest says why, and its "wrong on purpose" controls must keep failing.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core