Repository navigation
Releases: Particle-Academy/fine-print
Release list
v0.5.0
Portable content: fine-print reads into, and writes from, the plain-JSON
format the four document packages use to move content between each other
(fancyContent: 1, normative in fancy-conformance 0.37.0
suites/exchange/SPEC.md). No 0.4.0 was released: 0.5.0 is the floor the
sibling readers' PDF bridges will require (conflict: <0.5). Nothing here is
breaking; existing documents write the same bytes.
Added
Agent::fromPortable($content): portable content to fine-print blocks,
{ok, blocks, report}. Tables keep header rows, spans and widths; numeric
columns are right-aligned (header included); typed values become their
display text (typed-as-text); formulas are never carried, only their
values (formula-as-value). Invalid content is refused as
{ok: false, refused: {reason, path}}and produces nothing.- An inline
{"type": "portable", "content": ...}block, accepted anywhere a
block is (the document, a quote, a table cell).validate()checks it
closed and against the exchange limits (content_invalid,
content_too_many_*,image_too_large,content_too_large, with a JSON
Pointer).validateAndRepair(),toBytes(),write()andlint()
expand it throughfromPortable, deterministically:errorsgains one note
per expansion and the result gainsreport, located into your document.
Invalid portable content is left in place and refused, never repaired. Agent::extract($pdf, $selector, $options): a PDF's pages as portable
content, through the agent-read guard (redact by default). Every
zero-width, bidi-control and Unicode tag character left is stripped and
counted, under any policy; the report carries counts and page locations,
never document text.{pages: [first, last]}, 1-based. Not gated: it is a
read.Agent::toPortable($read, ...)is its pure half.Agent::export($pdf, $target, $options, $policy)fortextandmarkdown
(## Page nand escaped paragraphs). OFF by default: a host-built
Exchange\ExportPolicyenables targets, a null policy allows nothing, and
agent options cannot enable anything (an unknown option throws). Refusals
in the pinned order:export_unsupported,export_disabled,
export_unavailable,export_refused, then a limit code.
Agent::exportToolDefinition($policy)lists only the enabled targets.Agent::describe($doc): a compact summary for the model, as last-word's.Agent::validateContent($content, $options).- The JSON schema describes the portable node as
$defs.portable, beside the
model ($defs.blockOrPortable);$defs.blockis unchanged. The agent guide
has a section on it. Both are now hash-pinned. - Conformance: every
exchange/*suite naming fine-print (content 56, limits
32, policy 25, report-kinds 40, from-fine-print 9, to-fine-print 11 rows)
and fine-print's half ofdoc-model/shared(20) run in the suite against
particle-academy/fancy-conformance0.37.0, an exact require-dev pin.
Development only: no runtime dependency was added.
Fixed
- A link with a trailing line feed was accepted.
Schema::LINK_PATTERNended
in$, which PCRE also matches before a final newline, so
"https://example.com\n"passed both link checks and reached the PDF's
/URI. The pattern is now anchored at the true end (D). Upgrade normally;
only such links change: validation now reports them and repair removes them,
keeping the text.
v0.3.0
Added
- Page decoration.
page.background(#RRGGBB) paints the colour
full-bleed on every page, before any content.page.borderdraws a frame
measured from the page edge rather than from the content, so it is the
same on a page holding one line as on a full one:style(single, double,
dashed, dotted, none; single by default),width(the whole frame, 0.5 by
default; a double is two rules of a third each),color(black) and
inset(points from the edge to the outside of the frame, 24 by default).
Frames are closed rectangles, so corners meet. Validation refuses a frame
that leaves no room inside it; repair removes an invalid background or
frame and says so; the JSON schema and agent guide describe both. The
frame does not move the text: new lintframe-overlapwarns when a margin
is narrower thaninset + width. White text on a dark page background is
not flagged as hidden. Nothing changes for documents without them; their
bytes are identical. - PARITY:
last-word(and its JS and Python ports), whose document model
this shares, has neither key yet. Its validator ignores them, so the same
document writes a .docx without the decoration, and its JSON schema
(additionalProperties: falseonpage) rejects them.
Fixed
- Letter spacing no longer leaks from one text run into every later line.
The writer reset its record of character spacing (Tc) at each text
object, butTcis graphics state and persists acrossBT/ET, so after
one run withletterSpacingthe rest of the page was drawn tracked while
layout measured it untracked, and lines overflowed their frame. An explicit
letterSpacing: 0did not help, because it matched the stale record and no
Tcwas written. Spacing is now tracked per page. Upgrade normally; nothing
changes for documents that never setletterSpacing. The writer emits no
other persistent text-state operator (Tw,Tz,TL,Ts,Tr), so
none had the same defect.
v0.2.1
Fixed
- Restore ordinary PDF rendering performance after 0.2.0's transparency work.
Plain pages use RGB spans with colour converted once per paint, skip alpha
storage and reuse identical opaque rows in a bounded cache. Images use the
direct RGB route when equivalent. Upgrade normally; pixels, completeness,
cache revision and strict page-image defaults stay the same. - Allocate page alpha only when an actual non-Normal blend needs it. Replay
once to recover earlier transparency under the same time, memory, pixel and
operator budgets. Preserve image/stencil component clamping from 0.2.0. - Deduplicate memory preflight snapshots while retaining copy-on-write guards
and detecting runtime PHP memory-limit changes. Refuse extremely wide raster
edges before their sparse cell arrays exhaust PHP's hard memory limit.
Validation
- Six owned opaque fixtures keep byte-identical RGB output to 0.2.0 and
allocate no page-alpha, shape or offscreen planes. A fixed 1.75x budget
against an independent in-run RGB raster reference catches the regression;
the released 0.2.0 measures 3.35x on the same guard. - Alternating same-process seven-run medians before/after (ms): blocks
34.91/13.97, deck 394.88/261.96, P2 report 109.40/71.67,
P2 text 35.57/21.01, standard fonts 176.86/120.14, sampled image
168.91/97.53. These are within 9% of 0.1.0 or faster while retaining the
newer allocation guards; timings are evidence, not absolute CI thresholds. - All twelve local Quartz page/scale PNGs remain byte-identical to 0.2.0.
No dependency, font, golden fixture or workflow is changed.
v0.2.0
Mac Quartz PDFs that use ordinary transparency groups and device-space soft
masks now render complete pages, so the default strict Laravel page-image
route can serve them. Upgrade normally; requireComplete stays enabled.
Cached page images are refreshed automatically, including custom namespaces.
Added
- Form transparency groups with isolated/non-isolated backdrops, knockout,
native Gray/RGB/CMYK blending and invocation alpha/blend/mask applied once.
Equivalent normal groups draw directly without an unnecessary buffer. - ExtGState Alpha and device-space Luminosity masks with BC backdrops,
Identity, sampled, exponential and bounded stitching transfer functions.
Mask coordinates are fixed whengsinstalls them. AIS shape semantics,
explicit/soft image mask precedence and atomic path/glyph fill+stroke. - All sixteen PDF blend modes, including Hue, Saturation, Color and Luminosity.
Page groups blend before the white display background is applied. - Cropped offscreen and soft-mask buffers count against the shared live
maxRenderPixelscap and are checked against the memory budget and PHP's
hard memory limit before allocation. Recursive masks share the form-depth
guard. Calculator transfers, calibrated luminosity masks, unknown group
spaces/modes and TK=false text remain reported approximations.
Validation
- Own synthetic Quartz fixtures, Chromium goldens for every blend mode and
alpha/luminosity masks, child-process memory checks and cache-upgrade coverage.
Bounds remain MAE <=2 and <=1% pixels off by >64. Chromium's measured
isolation/knockout limitation is explicit; those cases use independently
expanded ISO equations and tests proven red when I or K is ignored. - A local, uncommitted six-page Quartz document renders complete at 1x and 2x.
No third-party runtime dependency or fixture is added.
Changed
- BREAKING for custom pixel caps:
maxRenderPixelsnow counts all live
rendering buffers, rather than only the page. If your cap was exactly one
page's area, allow room for group/mask buffers or request a lower scale.
v0.1.0
First release. Install with composer require particle-academy/fine-print;
there is no migration. Optional Laravel serving needs an app-owned resolver
that authorises each file; see the README's service-provider example.
Validation: 777 tests, 6049 assertions; reference compatibility against
fancy-labs' document models; no PDF lab lane exists.
Added
-
Optional Laravel 13 page-image serving: route-cache-compatible
PageImages::register(), app authentication and per-request file
authorisation before cache access.PageSource::fromStream()reads only
an already-authorised stream with bounded bytes, memory and time. -
Private watermarked PNG responses: a shared clean raster is restored
from a versioned/checksummed compressed-RGB packet, never PHP object
deserialisation. The authenticated viewer's name, email or ID and server
request time are burned into a copy on every request. Labels are bounded
WinAnsi glyph data. No PDF bytes or another viewer's marked image are served. -
Serving limits: three seconds for cache/render/watermark/PNG, 32 MB
source files, eight million render pixels, 8192 pixels per side and quarter
scales up to 4x by default. Incomplete pages return 422 unless the host opts
in; size, time, memory and malformed-input refusals return clean errors. -
Real Laravel 13 routing/cache/http/events components as TEST dependencies
for the optional serving adapter. This is the framework test-dependency
exception in workspace RULES.md; no framework runtime requirement is added.
Laravel v13.38.0 released 2026-10-20 (freshness checked before adding). -
P3d clipping and transparency subset: nonzero/even-odd path clips,
Form BBox, text clips accumulated through ET, constant CA/ca, grayscale
image soft masks, explicit stencil and colour-key masks. Separation/DeviceN
uses type 2 exponential or type 0 multilinear sampled tint transforms
(up to four inputs/outputs, all PDF sample depths). Unsupported groups,
blend modes, calculator functions and advanced masks remain reported. -
P3c inline images and our own baseline JPEG decoder: Huffman DC/AC,
dequantisation/IDCT, Gray/RGB/YCbCr/CMYK/YCCK, subsampling, separate scans,
restart markers and byte stuffing. Adobe APP14 overrides ColorTransform;
PDF Decode is applied afterwards. Inline boundaries preserve binary EI
bytes, including within compressed samples and JPEG metadata. Progressive
and other unsupported codecs paint reported checkerboard placeholders. -
P3c image XObjects (first step): packed Gray/RGB/CMYK samples at
1/2/4/8/16 bits, Flate/LZW and PNG/TIFF predictors, Decode arrays, Indexed
palettes and fill-colour stencil masks, transformed with bounded
nearest-neighbour sampling.maxImagePixelsdefaults to 16 million,
checked before decoding. Unsupported/damaged images remain reported. -
A PDF reader in pure PHP, with no runtime dependency beyond PHP's bundled
zlib,mbstring,ctypeandjson. Reads the header, cross-reference
tables and streams, hybrid files, incremental updates (newest object wins)
and object streams. Decodes FlateDecode, LZWDecode, ASCII85Decode,
ASCIIHexDecode and RunLengthDecode, with PNG and TIFF predictors. Image
codecs remain opaque during text extraction; the renderer decodes baseline
DCT itself and reports unsupported JPX, CCITT and JBIG2 images. -
Text extraction in reading order (
Document::text()), through the font's
ToUnicode CMap, its encoding and/Differences, including two-byte Type0
fonts and text inside form XObjects. -
Page count and page geometry (
Document::pageCount(),pages()), with
MediaBox, CropBox and Rotate inherited down the page tree. -
Metadata, XMP, outline and annotation text (
metadata(),xmp(),
outline(),annotations()). -
Active-content detection (
Document::activeContent()): JavaScript,
OpenAction, additional actions, Launch, URI, SubmitForm, ImportData, remote
go-tos, embedded files, RichMedia, XFA and AcroForm, each with its object and
key path. Detected and reported, never executed or followed. Scripts are
identified by length and SHA-256; no finding carries a script body, a launch
target or a URI. Name escapes (/J#61vaScript) and object streams hide
nothing. Forms are not supported: they are reported, never filled or
submitted. -
Agent::read(), the agent-read guard. Every string handed to an agent
(page text, metadata, XMP, outline, annotations) is scanned for instructions
aimed at an AI, hidden text (invisible render mode, white on white, sub-point
size), off-page text, and zero-width, bidi and Unicode tag characters (tag
characters are decoded and checked for what they spell). Policies areflag,
redact(the default) anddiscard, with a severitythreshold. Each
finding reports its location; excerpts are opt-in (excerpts: true) and are
for human audit logs only. -
Agent::pageCount(), for hosts. It throwsRefusedExceptionon a bad file. -
Hostile-input limits (
FinePrint\Limits): file size, object count
(judged on what an xref declares, before allocating), nesting depth,
per-stream and total decoded bytes, decompression ratio, operators per page,
form depth, page count, time and memory. A breach is aRefusedException
with a stablereason, andAgent::read()returns it as
ok: false. Files that need a password are refused (empty-password files open, below). -
Repair: a missing, truncated or lying cross-reference table is rebuilt by
scanning, including objects inside object streams. Cycles in references, the
page tree, the outline, form XObjects and/Prevchains are survived and
listed inwarnings. -
A PDF writer (
Agent::toBytes(),Agent::write()), from the same JSON
document model asparticle-academy/last-word: headings, paragraphs with
styled runs (bold, italic, underline, strike, inline code, colour,
highlight, size, letter spacing, font, links), nested lists, tables (column
weights, width, alignment, borders, cell padding, fills, column and row
spans, header rows repeated on every page), code, quotes, PNG and JPEG
images, page breaks and rules; letter, legal and A4 in either orientation,
margins, and running headers and footers with{page}/{pages}. Text is
wrapped and justified with real font metrics; headings keep with the block
after them, and no paragraph leaves a single line at the foot or head of a
page. Nochartblock yet: validation says so and repair drops it. -
Fonts: the standard 14 text faces with their metrics (widths derived from
the metric-compatible Liberation fonts through this package's own TrueType
reader, and checked against Adobe's AFM values), written with explicit
/Widths. Host-registered TrueType fonts are embedded as subsets (Type0,
Identity-H, with a ToUnicode CMap, so text extracts exactly, astral
characters included). Fonts come only from the host'sfontsoption, never
from the document. CFF (.otf) fonts and collections are refused for now,
as are fonts whose licence forbids embedding. -
Images: JPEG embedded as-is; PNG passed through untouched with a PNG
predictor when PDF can carry it, and decoded with zlib only when it cannot
(alpha,tRNStransparency, interlacing), with an 8-bit soft mask. Every
PNG is checked to inflate to exactly the size its header promises, so a
decompression bomb is refused instead of copied into the PDF. -
Output is PDF 1.7 with a classic cross-reference table, and is
deterministic: no timestamps, and the file ID is a hash of the body. -
The writer never emits active content: no JavaScript, open or additional
actions, launch actions, forms, widgets, embedded files, rich media or XFA,
whatever the document contains. Links must behttp,httpsormailto
and are checked twice. Model text is written only as hex strings, and
zero-width, bidi-control, tag and control characters are removed, so the
writer cannot carry hidden text. -
Agent::validate()andAgent::validateAndRepair(), in last-word's
shapes; repair removes a disallowed link (keeping its text), raises sub-1pt
text to 1pt, and drops charts and unreadable images, reporting each change. -
Agent::lint(): writes the document, reads it back through the guard,
and reports what will be wrong: unencodable characters, substituted fonts,
words broken mid-word, overflowing blocks, stranded headings, sparse pages,
missing page numbers, placeholders, header/footer overlap, and anything the
read-back guard flags. Findings never quote the document's text. -
Agent::fromArray(): rows of data as a finished table document (header
row, numbers right-aligned with one precision per column, optional caption
and source), as holy-sheet'sfromArraybuilds a workbook. -
Agent::jsonSchema()andAgent::guide(), the agent guide in
agent-guide/building-pdfs.md, hash-pinned by a test. -
The agent is told when content was redacted (owner ruling, 2026-10-21:
redact stays the default).Agent::read()now returnsredacted(count,
byRule,where: one location per item cut, never its text),notice(the
same in plain words, namingAgent::readUnredacted()as the way to get the
original) andunredacted: false. Underdiscardthe notice says the
content was withheld and does not offer the second step. Nothing was cut:
both are null. -
Agent::readUnredacted(): the deliberate second step. The original text
with every finding attached,unredacted: true, and a notice that the text
is data, never instructions. Always theflagpolicy; apolicyoption is
ignored. Excerpts stay off unless the host asks. The agent guide explains the
flow. -
Encrypted PDFs that open with an empty user password are read (owner
ruling, 2026-10-21). The standard security handler in full: RC4 40-128-bit
and...