A page's /Contents array is one content stream in pieces (ISO 32000-2
§7.8.2), so a cm, a q that is still open, the font and the colour set in
one piece still apply in the next. Edit text read every piece with a
fresh state. AutoCAD scales the whole page in its first stream
(0.12 0 0 0.12 0 0 cm) and draws in the following ones, so the text
boxes of its plans landed far off the page and no label could be edited.
Co-authored-by: Oliver Schiemann <228913879+Checkov23@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Issue #96: on Linux, draw on the GPU that drives the monitors
Preferring the low-power GPU (issue #8) picked the integrated GPU on
desktops whose monitors are all on the discrete one, so the window was
black (Hyprland) or the app panicked with "surface isn't supported"
(GNOME). On Linux, pick an adapter that supports the window's surface
and whose PCI ids match a GPU with a connected monitor (from
/sys/class/drm); integrated still wins among those, so hybrid laptops
keep the issue #8 behaviour. WGPU_POWER_PREF still overrides.
* Use the PdfCraft name for the fake sysfs test folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
CFBundleDocumentTypes listed PDF by extension and MIME type only.
LaunchServices keeps those as plain tags (.pdf, application/pdf)
instead of resolving them to com.adobe.pdf, so Finder greys the app
out under Open With > Recommended Applications and a forced open
fails with 'cannot open files in the PDF document format'.
Add LSItemContentTypes com.adobe.pdf (keeping extension and MIME for
backward compatibility). Role stays Editor, rank stays Alternate, so
the app is offered without hijacking the default. Verified with
lsregister -dump: bindings change from '.pdf, application/pdf' to
'com.adobe.pdf' with claimed UTIs com.adobe.pdf; plutil -lint and
xmllint --noout pass.
Follow-up to #73.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Print: read lpstat untranslated so printers show on non-English macOS
On a Mac whose interface language isn't English, the Print dialog
listed no printers. `lpstat -p -d` follows the UI language (here
Polish: "drukarka … jest bezczynna", "domyślny cel systemowy: …"), and
parse_lpstat only knows the English "printer " and "system default
destination:" prefixes, so it matched nothing.
- lpstat now runs with LC_ALL=C and LANG=C, which is enough on Linux.
macOS CUPS ignores both and follows AppleLanguages unless SOFTWARE
is set, in which case it uses LANG, so SOFTWARE is set too.
- New test: localized output parses to nothing, and the command sets
all three variables.
Checked on macOS with a Polish UI: only SOFTWARE=1 LANG=C gives English
output, and the dialog now lists every printer with the right default.
lp's error messages stay localized; they are shown, never parsed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Use the PdfCraft name in lpstat's SOFTWARE variable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Reject invalid xref ranges before arithmetic or narrowing casts, allowing the existing reconstruction path to preserve intact objects and record the repair reason. Cover both tables and streams through Document::open.
Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A document could open any address with no confirmation: a link click, a
push button's URI action, a one-line button script mapped to a URI action,
or app.launchURL from JavaScript (on by default). That allowed sending
form data to an outside site, or handing file:, smb: and app-handler
addresses to the operating system.
All four now go through PdfCraftApp::request_document_url:
- engine::links::document_url accepts only http, https and mailto, up to
2048 characters, and refuses control, zero-width, bidi-override and
non-space whitespace characters, malformed schemes and web addresses
without a host.
- Accepted addresses wait in an "Open this web page?" dialog that shows
the real host (engine::links::host drops user:password@, and treats \
like / as browsers do, so https://a.example\@b.example shows a.example)
and the full address. Cancel is the highlighted button; Escape and
clicking outside cancel. While one request waits, further ones are
dropped, so a script can't stack dialogs.
- Refused addresses are reported in a notice and never opened.
PdfCraft's own links (Help, About, updates) still use open_url.
Preferences > JavaScript now says scripts need permission to open a web
page. Tests: engine unit tests for the policy and host parsing, and
crates/ui-egui/tests/document_links.rs driving the link, button, button
script, app.launchURL and Escape paths (all fail against the old code).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Automation: --root refuses every outside path the same way (#136)
With a root set, `resolve` canonicalized a path before checking it against
the root, so an existing outside path got "is outside the allowed
directory" while a missing one got the OS error ("No such file or
directory", "Not a directory", "invalid path"): a confined MCP client or
script could probe which files and folders exist anywhere on the disk.
Containment is now decided before anything about an outside path is
reported:
- `..` is resolved by name first (as Windows does), so a path can't pass
through an outside folder on its way back in;
- on Windows another network share or device namespace (`\\host\share`,
`\\?\UNC\...`, `\\.\...`) is refused by name, without contacting it;
- the deepest existing ancestor, links followed, must be inside the root
before anything is said about the rest;
- a broken link is refused the same way, so whether its target exists
stays hidden too.
Every outside path now gets `<path> is outside the allowed directory
<root>`, existing or not, file or folder, read or write. Filesystem errors
such as "not found" are still reported for paths inside the root.
Behaviour change: on Unix, `..` now means the parent by name, as on
Windows (`link/../x` is `x` beside `link`), and a write such as
`new/../x.pdf` that stays in the root now succeeds instead of failing with
"'..' is not allowed here".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* CLI: script images stay inside --root (#137)
`pdfcraft-cli run --script steps.json --root DIR` wrote each step's
`"out"` PNG with `std::fs::write`, never through the root check the tools
use: an absolute `"out"` created or replaced any file the process could
write, and a relative one landed in the working directory instead of the
root.
The image is now saved through the new `Automation::write_output`, which
uses the same resolver and atomic write as a tool's own output: outside
paths get the usual "is outside the allowed directory" refusal (the step
fails, nothing is written), relative paths resolve inside the root, and
without --root the path is written as given, in place as before (so
`/dev/stdout` still works). `run <tool> --out` goes the same way.
Found while testing this, the same "." problem in two tools:
- a write path naming a folder, such as "." (the root itself), staged its
temporary file beside that folder, i.e. outside the root for ".",
overwriting and then deleting any file there called
`.root.pdfcraft-tmp`. `write_atomic` now refuses a folder before
writing anything;
- `image_save` adds an extension to a path without one, so "." became
`root.png` beside the root. It now refuses a folder first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Automation: files named after a document stay in the output folder
Found while reviewing the --root fixes for #136 and #137. Folder outputs
name their files after the document and joined that name straight onto the
resolved folder, without checking the result against the root:
- `doc_export_images` and `doc_export_all_images` used the whole document
name, so `doc_create` with `name: "../../escape"` followed by
`doc_export_images` wrote `escape_page_1.png` outside the root (an
absolute name went anywhere);
- `page_extract` (separate files), `doc_split`, `ocr_recognize_files` and
`action_run` took the last component, but on Windows a name such as
`x/C:evil.pdf` gives `C:evil`, and `folder.join("C:evil-page1.pdf")`
replaces the folder with a path relative to the current directory on C:.
Every such name now goes through `child()`, which turns separators,
colons and control characters into `_` (and prefixes a name of only dots),
so the file is always a direct child of the folder. `with_root` also
refuses a root that is not a folder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Redact: operators split across a page's content streams are redacted whole
A page's /Contents array is one content stream in pieces, and a writer
may split it between any two tokens (ISO 32000-2 §7.8.2). Some office
and layout exporters do: a TJ array ends one piece and its TJ operator
starts the next, or a marked-content dictionary closes in the next piece.
Redaction parsed each piece on its own, so such an operator was cut off
from its operands. Glyphs shown by a split TJ were never checked against
the marks: Apply drew the black box but left the text in the file, and
Verify reported nothing under the box. When the piece holding the
operands was rewritten for another mark, they were dropped, so the text
after it vanished and the next piece began with an operator without
operands, which other readers reject.
process() now parses the pieces joined, gives each operator back to the
piece its keyword is in, and rewrites every piece a split operator spans.
Pages whose pieces split only between operators are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Use the pdfcraft crate names after the rename
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The app, crates, binaries, bundle id, packaging, icons, screenshots and docs are now
PdfCraft / pdfcraft (repo storytold/pdfcraft). On first launch the desktop app moves the
PrintCraft settings and crash-recovery folders to the new name, and both apps read settings
saved under the old storage key, so upgrades keep recent files, preferences and unsaved work.
The signing test fixtures keep their PrintCraft subject names (they are binary).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The MSI registered no file association. The only PrintCraft entry a
user could pick in "Choose an app on your PC" was the advertised Start
Menu shortcut, which Windows can't run as a file handler ("This app
can't run on your PC").
The MSI now uses Windows' Default Programs registration: a
PrintCraft.Document ProgID (open command, icon), .pdf OpenWithProgids,
Applications\printcraft.exe (friendly name, supported types), and
Capabilities\FileAssociations listed under RegisteredApplications.
PrintCraft appears under "Open with" and in Settings > Default apps.
It never takes over an existing default: .pdf's own default value is
left alone, and the user chooses.
Checked locally with WiX 5.0.2: the MSI builds, passes ICE validation
(only the existing ICE61 note on same-version upgrades), and its
Registry table holds exactly these values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
release.yml's comments say "See docs/releasing.md", but the workflow came
over from PhotoCraft without its document. It now describes PrintCraft's
pipeline as it is: cutting a release and test runs, every artifact
(including Windows on ARM64), the craft-fonts pin, each platform's
packaging, signing and notarization, the release-environment secrets,
and the packaging checks.
Written from release.yml and the packaging scripts. It says plainly that
the binaries don't embed the commit yet, and packaging/env.sh no longer
points to a build_info.rs that doesn't exist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md, CLAUDE.md and crates/fonts/README.md linked to storytold/craftrules, which outside contributors can't open. The links stay for team members, now labelled internal, and AGENTS.md says up front that every rule a contributor here must follow is stated in AGENTS.md and CLAUDE.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its Status line said 38 tools and listed form authoring, redaction tools and MCP page-image resources as planned; 123 tools and all three ship. It now names the areas the tools cover and points to printcraft-cli tools for the current list instead of a count that goes stale, and the MCP section lists the resources/* methods and resource templates. ROADMAP's M13 row said 91 tools.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Field Properties > Options > Check Box Style (Button Style for radio
buttons): Check, Circle, Cross, Diamond, Square or Star, as Acrobat
offers. The choice is stored in each widget's /MK /CA with Acrobat's
ZapfDingbats codes (4 l 8 u n H), so other viewers draw the same mark,
and PrintCraft draws it as paths, needing no symbol font. Styles set
by other apps are read back too; the defaults stay a check for check
boxes and a circle for radio buttons.
- forms: CheckStyle, check_style(), FieldProps::check_style; set_props
writes /MK /CA (resolving an indirect /MK so its colours survive) and
redraws; text and choice fields refuse a style.
- engine: Document::field_check_style.
- automation: form_set_props takes check_style, form_fields reports it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- #93: field detection already ran when Prepare a form starts on a
document without fields, and as the form.detect command, but nothing in
the tool panel offered it. Prepare a form > Fields now lists Detect
form fields; on a form that already has fields it only adds new ones.
- #95: with several fields selected (Shift/Cmd-click), Delete and the
context menu's Delete removed only the right-clicked one. They now
remove every selected field as one undoable step ("Delete 3 fields").
One prepare::delete_selected serves both.
Editing the shared properties of several fields at once is still to do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The session's log line: issues #70, #72, #73, #74 and #76 fixed, and
#79, #80 and #81 noted as open. The parity tables are refreshed from
`cargo xtask parity` (805 features, 50.2% shipped, 53.7% weighted),
with the by-area shares and the 0.2.1 release. Docs only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the Text tool, a click on the page replaced the box being typed
with a new empty one, throwing the text away. A real mouse click
usually arrives as press and release in one frame, so the page saw the
click before the editor saw its text box lose focus.
- The page click now finishes the open box first (one finish() shared
with the editor), then starts a new box where you clicked. Both input
timings end the same way, so the tool no longer drops back to Select
after one box.
- The box being typed gets Done (keep it, back to Select) and Discard
(as Escape) buttons beside it, as the reporter suggested. Pressing
them doesn't count as a click away.
- The box just kept is not selected while the next one is typed, so the
Format panel styles the new text.
- content_ui::page_input returns a bool; its Outcome::done was never
set.
Reproduced and checked in the real app on Windows with OS mouse and
keyboard input (SendInput): before, the first box vanished; now both
boxes stay and a third opens.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Glyphs are rendered on whole pixels: egui's sub-pixel binning draws
each glyph at quarter-pixel offsets, which it notes makes text
blurrier, most visibly at the 100-150 % scaling common on Windows.
- Light theme: a mild gamma (0.75) on glyph coverage darkens the
antialiased edges of dark text, which egui renders linearly (thin and
grey next to the system's text). The dark theme keeps egui's curve.
- The faintest text colour met only 3.3:1 (light) and 3.9:1 (dark).
It is now #6B6B73 / #97979E, so every text colour meets WCAG AA
(4.5:1) on every surface in both themes, enforced by a test.
Compared in headless renders at 1x and 2x: crisper stems at 1x, little
change at 2x.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Linux package description, AppStream metadata (categories,
keywords, a screenshot that doesn't exist), the MSI's comment and the
web embedding example still described PhotoCraft. They now describe a
PDF workbench, AppStream lists application/pdf, and the screenshot is
one of ours.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finder hands files to an app as an Apple event, not on the command
line. winit doesn't handle it, so AppKit answered "PrintCraft cannot
open files in the PDF document format". The bundle also declared no PDF
document type: its Info.plist still carried the sibling image editor's
types.
- Info.plist declares PDF (by extension and MIME type) as a document
type, role Editor, rank Alternate: offered under Open With and
settable as the default, without taking over on install.
- apps/printcraft registers open-documents and quit Apple event
handlers through fmv-macos-events (=0.1.0, the audited safe wrapper
PhotoCraft uses, since the Objective-C interop needs unsafe), before
the launch event arrives.
- printcraft-ui-egui gains OsEvent and PrintCraftApp::os_events, polled
every frame: Open opens each file as File > Open does; Quit closes the
window like its close button, so unsaved changes are asked about.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Text extracted from a PDF can hold C0 control characters (fonts without
a usable /ToUnicode), which XML forbids, and Word refuses the whole
document over one ("Word experienced an error trying to open the
file"). The Word, HTML and RTF writers now drop the characters XML
forbids. Also:
- the page size stays within Word's 22 in (a till receipt is taller,
and Word read it as a negative height), and margins shrink on small
pages so text keeps room; images fit the real text width;
- a non-finite font size falls back to 12 pt;
- zip entries get a valid DOS date (1980-01-01) instead of zero.
Checked by opening the exports in Microsoft Word: control characters,
U+FFFE/U+FFFF, a 33 in receipt with a 40-row table, narrow, tiny and
wide pages all open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Next page went to the right-hand page of the spread already on screen,
so the view didn't scroll and the current page snapped back to the
left-hand one. Next and previous page (Ctrl/Cmd+arrows, the rail's
buttons and the NextPage/PrevPage actions) now step a spread at a time
in two-page view, with or without a cover page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shippori Mincho (and its OFL file) leaves assets/fonts/: the Japanese fonts now
live in storytold/craft-fonts. printcraft-fonts gets craft-fonts' build.rs recipe:
with CRAFT_FONTS_DIR=<checkout> it embeds the manifest's fonts as CRAFT_FONTS
(wasm32: only BIZ UDPGothic Regular); without it CRAFT_FONTS is empty and the
app builds, tests and runs as before, minus the Japanese face.
- SHIPPORI_MINCHO is now Option<&[u8]> from CRAFT_FONTS; japanese_glyph
(was shippori_glyph) uses document_japanese_font() (Shippori Mincho, then
BIZ UDMincho, then any Jpan face) and returns GlyphError::NoFont without one.
- Editing PDF text into Japanese without craft-fonts is a clear EditError that
leaves the page untouched.
- UI: every Jpan craft font (BIZ UDPGothic first) is the last fallback of
every egui family, sharing the same static bytes.
- Tests: Japanese glyph/edit tests skip without craft-fonts; new UI tests for
no-tofu Japanese with craft-fonts and working fonts without it.
- CI: the Linux test job checks out craft-fonts at a pinned SHA and runs the
tests with CRAFT_FONTS_DIR. Releases build every platform with it
(CRAFT_FONTS_REQUIRED=1) and ship each font's OFL as OFL-<family>.txt.
- ATTRIBUTION.toml gains [[build_input]] (checked by xtask assets); NOTICE,
AGENTS.md, CLAUDE.md, README and docs say fonts live in craft-fonts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It was only for finding the WARP crash (fixed in the previous commit) and
was merged with #66 by accident; it ran a 60-iteration crash-dump loop and
edited the runner's registry on every push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On GitHub's Windows 11 ARM64 runner (no GPU, so wgpu uses Microsoft's WARP)
tests/view.rs died with STATUS_ACCESS_VIOLATION in about 1 run in 4. A crash
dump puts it inside WARP's pixel-shader JIT
(d3d10warp!PixelJitProgram::ClassifyVars, on WARP's own thread pool) when the
two tests that render pixels compile shaders on separate devices at once.
Single-threaded, or each test alone, it never crashed (180 runs); bigger
thread stacks didn't help (6/30 vs 7/30), so it's that race, not our code.
The app has one device on a real GPU and can't hit it.
Those two tests now hold a per-binary lock for their whole run (taken before
the harness, so released after its device drops). Other test binaries render
from at most one test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes the Windows installer: the Start Menu shortcut and Add/Remove Programs icon were blank. (printcraft#57)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cross-compiled ARM64 binary can't run on the x64 packaging runner; the
windows-arm64 workflow installs and runs it on ARM64 instead. (x86 builds
still run there: x64 Windows runs them.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- package.ps1 takes -Arch arm64 (aarch64-pc-windows-msvc, WiX -arch arm64)
and now checks each binary's PE Machine as well as its Subsystem, so an x64
build can never ship labelled arm64.
- release.yml builds, signs and packages a Windows ARM64 MSI and portable zip
next to x64 and x86, cross-compiled on the x64 runner so signing and WiX
run exactly as they do for x64.
- New windows-arm64.yml (PRs touching Cargo/packaging, main, weekly):
packages the ARM64 MSI the same way (unsigned), installs it on a Windows 11
ARM64 runner, checks both installed binaries are ARM64, opens/reads/edits/
renders/saves a PDF with the installed CLI, uninstalls; and runs the whole
test suite natively on ARM64. The MSI is kept as an artifact for 7 days.
- README: Windows builds for x64, x86 and ARM64.
Requested by @maxffarrell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-ups on top of the table detection:
- Each block's column comes from a precomputed map and a binary search
instead of scanning every column's members, so pages with many text
blocks stay linear.
- A table has at most 64 columns. Rows are materialized to every column, so
a hostile page laid out as a staircase of blocks would otherwise make
(blocks / 2)^2 cells. Test: a_staircase_of_blocks_is_not_a_huge_table.
- RTF: a row's cell boundaries are written before its cells (RTF requires
it), and a spanning cell ends at the last column it spans. The test checks
the Totals row now spans two columns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
XFA forms aren't read yet, and opening one gave no hint: a dynamic XFA form
showed only its "Please wait…" placeholder page, and a static one looked like
an ordinary form though Acrobat shows its XFA data in preference to the
fields that get filled.
- render: DocInfo.xfa (Static / Dynamic) from /AcroForm /XFA; dynamic when
/NeedsRendering is true or there are no fields. Exported for doc_info
(automation and CLI) as "xfa".
- ui: the notice bar explains each case (above the form-fields notice); a
static form keeps its Highlight fields button.
- Tests: tests/xfa.rs (dynamic with and without /NeedsRendering, static,
ordinary forms, a malformed /XFA). Parity notes for form.xfa-static and
form.xfa-dynamic (still planned: this explains XFA, it doesn't support it).
Reported by @Sudachi-dev.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In Edit a PDF ▸ Edit text, dragging a paragraph's box moves it, and dragging
the handle on its right edge rewraps it to a new width; a click still opens
it for typing.
- edit: BlockStyle gains offset (user space) and width. rewrite_block puts the
move inside the paragraph's own q … Q as a cm, taken back through the CTM's
linear part so a page-space drag lands right in scaled/rotated content
(the CTM is now recorded at each line's origin). Non-finite numbers are
refused; widths are clamped to one character .. 14 400 pt.
- ui: BlockDrag on DocView; the box follows the pointer (or its right edge
does) and the release applies one undoable Edit text step. The width handle
shows on upright pages only (on a quarter-turned page the screen's
horizontal is the text's vertical).
- automation: text_edit takes dx, dy and width.
- Tests: engine (move, move under a scaling cm, narrower/wider rewrap,
non-finite and zero inputs), kittest pointer drags, automation tool.
Parity: new edit.text-move-resize (shipped).
Requested by @TCB13.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>