vendor/README.md conflicted: main took hayro-interpret patch 104 (RGB
colours in isolated DeviceCMYK groups). This branch's viewer overrides
become patches 105 (annotation_overrides) and 106 (appearance_overrides)
in the README, in vendor/hayro-interpret/src/interpret/mod.rs, in the
raster test and in docs/acroform-script-workflows.md.
* feat(a11y): auto-tag Apply tags headings by size and weight
Apply now chooses H1-H6 or P per text object instead of P for all.
The body style is the half-point size showing the most characters (bold
when most of those are bold). A short object (at most 3 lines, 200
characters, with a letter) is a heading when it is 1.15x the body, or
bold at 0.9x the body over a regular body in at most 2 lines. Heading
styles rank across the document, larger and bolder first, capped at H6,
and a level never skips past the one before it so heading-nesting
passes.
Size is Tf x Tm x CTM with q/Q tracked; bold is Metrics.bold. Text in
render mode 3 (an OCR layer) is not counted. Font metrics are measured
once per font object per document, at most 256 fonts.
AutoTagResult gains a headings count. Parity, gaps, roadmap and the
apply tool's note say headings are tagged.
Fixes#7062.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(a11y): rank heading styles by map and skip clip-only text
Look up each heading style's rank in a map built once after sorting,
so ranking is linear in the number of text objects. Text in render
mode 7 (clip only) paints nothing, so it is excluded from heading
evidence like mode 3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* prc: rebuild compressed tessellations whose normals are recalculated
A TESS_3D_Compressed (WD 7.8.9.7) was read to its end but never rebuilt,
so prc-render drew nothing for it. Rebuild the form whose normals are
recalculated, with three edge statuses per triangle, into a Mesh grouped
by face and styled by each face's line attribute.
The walk (tess/compressed.rs) takes a seed, then one apex per triangle,
from a reference or the next point in the frame of the edge it continues
across (WD 7.8.9.2), with the WD 12.3 Length, Unitize and MakeOrthoRep
arithmetic bit for bit. Arrays that do not fit, or an edge shared by more
than two triangles, leave the tessellation Compressed. Every triangle,
style and face range is charged to the element allowance first.
Stored-normals meshes, the one-status-per-triangle form, the planar bend
check, the best-fit search and partial salvage are later PRs.
Adapted from pdfcer (MIT, same author) at 0a249a73.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* prc: charge rebuilt compressed meshes after they fit; model_render test
The rebuilt triangles, their styles and the face ranges are now charged
to the element allowance once the arrays fit and before the face ranges
are made. Arrays the walk cannot fit stay Compressed and spend nothing,
so they no longer fail the file over a face count they never use.
Tests:
- a_rebuilt_compressed_mesh_is_charged_its_triangles_and_faces pins the
exact charge (4 triangles x 3 elements + 2 faces);
- arrays_that_do_not_fit_spend_no_triangles_or_faces;
- rebuilt_triangles_are_grouped_by_face_with_their_styles, a direct
by_face test with out-of-order faces, empty faces and styles;
- model_render's a_compressed_prc_model_is_rebuilt_and_drawn, end to end
from a PDF, cited in misc.3d-view; its hex fixture is the prc-render
builder's output, kept so by the_automation_fixture_is_the_compressed_triangle.
The walk's working tables are documented as bounded by the triangle
count rather than charged. The module docs now say the arithmetic
matches the WD pseudocode as read and that the traversal rules are
inferred, not WD text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* prc: charge compressed styles and face ranges before making them
A rebuilt compressed mesh's per-triangle styles were built before the
budget was charged, and also when the normals were stored and the mesh
was read past. The face attributes are now kept as read, and the styles
are built only for a rebuilt mesh, after the charge.
Each face range is a Range<usize>, two budget elements on 64-bit
targets, so it is charged at its size, not as one element.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* prc: bound the compressed rebuild walk by the allowance left
The walk's vertices, triangles, edge counts, edge log and stack were
allocated before anything was charged, and could hold several times the
decoded arrays. The walk now gets the elements the reader has left and
stops once its arrays, by length, would hold more; the tessellation then
stays compressed, as when its arrays do not fit the walk.
A face whose line attribute is 0 but whose behaviour is not now styles
its triangles, so the behaviour bits are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* prc: say the rebuild walk is bounded by the allowance
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* prc: cap the rebuild walk's first reservation by its limit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(render): an RGB colour in an isolated DeviceCMYK group blends in CMYK, as its ink
Part of #4887. An isolated DeviceCMYK group blended in CMYK only when every
colour in it had process tints (DeviceCMYK, DeviceGray); one RGB fill sent
the whole group back to RGB blending.
Vendored hayro-interpret patch (104): Color::ink_tints gives DeviceRGB,
CalRGB, Lab and three-component ICCBased colours the ink that draws back to
them through the bundled CMYK profile (new cmyk_inverse module: black
0.4*k0^2 + 0.6*k0^8 with k0 = 1 - max(r, g, b), matched to Acrobat on
synthetic pages; then cyan, magenta and yellow by at most 12 Newton steps
against the profile; one-entry per-thread cache). ICCBased CMYK still has no
tints.
Test an_rgb_colour_in_an_isolated_cmyk_group_blends_as_its_ink: 50% grey
under Difference with black within 10 levels of Acrobat's 137 112 106;
in-gamut colours draw as themselves; red within 8 of Acrobat's 238 32 37.
With the additive arms reverted the grey draws 93 97 96 and it fails.
#5228's control now uses an RGB image, which still has no tints.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(render): an RGB colour with a non-finite component has no CMYK ink
`to_rgba` turns a non-finite component into some finite colour, so the
inverse could not see it and gave that colour's ink. `ink_from_srgb` now
declines such a colour and the group draws it as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Print: app-managed colour converts printed pictures into a printer profile (#6269)
doc_print takes color_management (printer, the default, or app). With app,
color_profile (an ICC file or an installed profile's name), rendering_intent
and black_point_compensation convert each rendered page picture from sRGB
into the printer's RGB or grey profile before it is sent. app prints the
pages as pictures; CMYK and non-destination profiles are refused.
print_color_profiles lists the installed RGB and grey profiles.
The conversion is craft-cms at craft-print's rev, in pdfcraft_print::colour,
hooked into print_raster::rasterize. Raster carries the colour and is Clone
rather than Copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Print colour: look for installed profiles inside the read root when confined
With automation roots, print_color_profiles and doc_print's color_profile
name lookup read the system's colour folders, outside the roots. They now
look in the read root instead (as text_font_files does), check each file
against it before reading it, and are refused without read authority.
Under --root they look in the root.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* automation: keep colour profile discovery inside --root, links followed
Under --root alone, print_color_profiles and the color_profile name
lookup listed the root's .icc files without checking each against the
root, so a link inside it could read a profile outside. Each discovered
file now goes through the same --root resolution as any other read
(links followed) before it is opened.
colour_profile_links_out_of_the_root_are_not_read makes such a link and
checks it is neither listed nor found by name; it fails without the
check (the link is listed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* print: as_chunks for the grey fill; area print keeps managed colour
Clippy 1.99 (`chunks_exact_to_as_chunks`) flags the constant-width
`chunks_exact_mut(4)` in the grey conversion; use `as_chunks_mut::<4>()`.
main's print-area path built `Raster` without the new `colour` field;
build it through `PrintDraft::raster()` so a printed area is colour
managed like a printed page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Lock craft-cms at the craft-libs commit craft-print now uses
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(print): read only regular files as printer profiles, and no more than the cap
A FIFO or device named `.icc`/`.icm` in a profile folder was listed and
read, and opening a FIFO for reading waits for a writer. The listing now
keeps regular files only (links followed), `PrinterProfile::read` refuses
anything else before opening it, and the read goes through a limit one
byte past `MAX_PROFILE_BYTES`, so a file that grew after it was measured
is refused rather than read whole.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(flatpak): allow release checks and self-update helpers
* test: keep Flatpak permission parsing within strict Clippy rules
* chore: bump version to 0.21.1 for the Flatpak update fix
* fix(redact): remove a stroked path by the box its stroke paints
Path redaction tested a path's construction box against the marks. A
horizontal or vertical stroke has a zero-area box, which overlaps
nothing, so its drawing commands stayed beneath the overlay; a wide
stroke whose points lie beside a mark but whose ink reaches into it
stayed too.
The interpreter now tracks the line width, miter limit and join style
(`w`, `M`, `j`, and `LW`, `ML`, `LJ` through `gs`) and, for a stroking
operator, grows the box by half the width (at least one unit), times
the miter limit when the path has mitred joins, scaled by the CTM's
largest stretch, before the hit test. The bound is conservative: a
stroke near a mark may now go where before it stayed.
* address review: pad hairlines by half a unit, rectangles for their corners, square caps by sqrt 2
* review: test a kept clip against the path as constructed
* address review: a line state set between a path and its stroke no longer lets the stroke skip the redaction test
* address review: line-state operators read the operand before the operator
* fix(forms,xfa): find every password field before a save skips scrubbing it
The quick Password-flag check in pdfcraft-forms stopped silently at its
depth limit, and visited the tree in an order where a field the full
field walk reaches could be cut off on a long branch first. It now
answers true (read every field) whenever it cannot look as far as the
field walk, and also walks below fields adopted from page widgets.
XFA password classification walked AcroForm fields only 32 levels deep,
shallower than editing and dataset synchronization, and read the
datasets packet of an XFA array without the prefixes its preamble
declares, so a typed password could stay in the saved datasets. It now
walks as deep as the field walk, refuses to clear (and so to save) when
the tree is deeper than it can check, and reads the packet the way the
datasets writer does.
* address review: check every page widget's /Parent chain for the password flag
* address review: password scan follows the parents of nameless /Fields entries; field-walk completeness checked before the datasets packet is read
* address review: the field walk's completeness is decided after every root; the subtree scan starts at the last dictionary of the parent chain
* address review: a skipped visit does not replace the reach a field was classified with
* fix(edit,a11y): font aliases share the metrics built for their font
Text extraction and heading extraction built and kept font metrics per resource name, so
many names for one font object each held a copy of its Unicode and width tables.
* fmt
* address review: font metrics are cached by the object at the end of the reference chain
* fix(engine): run the renderer redaction check on redactions applied inside a batch
* Resolve redaction marks by page object and measure the edited document
The renderer check looked marks up by the page index they had when they
were drawn, so a MovePages later in the same batch pointed them at another
page's text, and it measured glyphs with page geometry from before the edit.
Each mark now carries the page object it was placed on, and the geometry is
read back from the document as the batch wrote it.
* review: skip marks whose page the batch deleted
* review: group marks by page once and refuse an ambiguous page tree
* address review: refuse a batch that deletes a marked page while adding pages
* merge main, keep both
* address review: a batch that applies redactions and then copies a page is refused
* fix(js): bound array text, month parsing, name registration and list work
- strings() (field value and colour setters, resetForm) copied every array
entry into its own String with no total: one long string listed a million
times was copied a million times. The copies together are now held to the
run's MAX_PAYLOAD byte budget.
- FormCalc parse_date copied and lowercased the rest of the text for every
month token of the picture, quadratic in both; it reads at most the longest
month name now.
- XFA global registration checked each child name against a Vec of every
name defined so far, quadratic in a wide form; it is a HashSet.
- FormCalc gather charged one step for a whole collection and list_push
charged nothing for a duplicate it dropped, so .all on same-named siblings
did quadratic work for one step a pair; every node visited costs a step.
- add_instance reserved only the new root's SOM before copying a subtree;
the copy's nodes, names and SOMs are charged to the byte budget first.
* address review: gather charges each call; check an array entry's length before copying it
* Hold an array entry to its widest escaped size before copying it
The pre-copy check compared the payload budget against a JS string's UTF-16
length, which is shorter than the escaped UTF-8 form it is copied to: a
string of quotes or non-ASCII text could allocate up to six times the
budget before the post-check rejected it.
The check now uses the widest escaped width, so the copy cannot exceed the
budget it is checked against.
* review: measure an array entry's escaped width before refusing it
* address review: count an array entry's escaped width without copying it
* address review: charge retained field text and colours to a run-wide budget
* fmt
* fmt
* merge main, keep both
* fix(render): charge field, tooltip, layer, bookmark and blank-comment text against shared allowances
* address review: charge comment text once, keep JS and field-action flags past the budgets
* review: count an unlisted field's actions through its whole subtree
* review: mark multi-select values the allowance dropped
* review: count only real actions, walk each field once, keep name selections
* address review: revisit shared field descendants from shallower paths, charge value separators, check every widget for actions
* merge main, keep both
* fix(sign): judge a shared signature value once per listing; dedupe DSS evidence by object number
* address review: shared signature values are judged by output index without copying the dictionary; a dead evidence reference does not hide a live one
* address review: repeated evidence references are skipped before they are resolved
* address review: each full evidence reference is tried once
* fix(redact): hidden-layer removal reads the default state as viewers do
hidden_layers ignored an indirect /BaseState and, with /BaseState /OFF,
let /ON override /OFF, so a group both lists named survived as visible.
The OCMD and OCG classifier also read /Type directly, so a membership
dictionary with an indirect /Type was never evaluated and its content kept.
The default state is now /BaseState (resolved), then /ON, then /OFF, and
/Type is resolved before classifying a group or membership dictionary.
* address review: follow chained /Type and /BaseState references, hash-set ON/OFF lists
* address review: a visibility expression naming a group through an alias reference fails closed
* fix(forms): a read-only radio button is not picked or left through set_value
* address review: use /V as the selection in the read-only radio check, keep reset going
* address review: radio read-only checks judge each button by the state it would get, and cover FieldValue::Widget
* fix(xfa): bound the name and path bytes data nodes and queued instances copy
* review: bound the name bytes of a whole data path and report each cap separately
* address review: the queued-instance allowance counts path segment buffers and the dedup copy
* address review: the queued path's spare capacity is counted
* fix(annot): count ink points as strokes resolve and cap the eraser's samples
Resizing and erasing a drawing resolved every /InkList entry without a total, so one
stroke array listed many times expanded freely; the eraser densified every segment into
up to 2,000 samples with no overall limit.
* review: refuse an eraser result that exceeds the stored ink point cap
* review: say the erasure would make the drawing too large
* address review: the eraser counts each stroke's final point against its sample total
clean_type3_font decoded and parsed every surviving glyph program of a
font, for every font. Many small font dictionaries sharing one
compressed CharProc multiplied that work without multiplying the stored
data, with a fresh allowance for each decode.
The resource names each indirect glyph program uses are now cached by
object across fonts (resource-name interpretation stays per font, since
each font prunes its own /Resources), and all decoding spends one
cleanup-wide DecodeBudget; past it the cleanup fails closed.
* fix(preflight): follow every /Next action and bound colour and intent work
Action chains followed an indirect /Next only when it named an array, and
skipped references inside a /Next array, so a forbidden action one indirect
hop behind an allowed one survived both verification and conversion; an
untyped dictionary with /P and /K was exempt from action checks whatever its
/S. Both are now followed and checked, and arrays are borrowed instead of
deep-copied per chain. Conversion treats a /Subtype /Widget without /Type as
an annotation, and takes forbidden annotations off their pages in one pass.
Device-colour inspection decoded each content stream with the 1 GiB
per-stream default and no total; it now shares one 1 GiB DecodeBudget and
reports unread content instead of treating it as clean. Type 3 CharProcs
shared by many fonts are read once, and declared() reads each output intent
once (at most 8).
* address review: no sRGB intent when colour couldn't all be read; count array reads in action chains; declare every output intent
* address review: cut only an over-long identifier; test with a filter that can't be read
* review: leave untyped structure elements out of the forbidden-action check
* merge main, keep both
* address review: a wide direct /Next array is refused before it is queued; queued references are resolved through aliases
* fix(optimize): the fragment search charges every operator of a group it reads
* address review: conflict markers removed; the fragment search charges every operator it reads to the production allowance
* address review: group reads are charged before a parse failure and count all four operators
* fix(optimize): walk shared resource dictionaries and name-tree kids once
* test(optimize): restore the file around the appended tests
* address review: import ObjRef in the optimize audit module
A script refused before it runs (too long, or nested too deeply) left the
realm untouched but reported no timers. The viewer keeps only the timers
a run reports, so a button or console script over the limit stopped every
page timer. The realm now reports the timers it still has on that path.
The viewer built the page actions' run and the timers' run before it
handled either, so a due timer ran before what the page's open action
asked for: on a page that goes on to another, before that page's close
action could stop it. The timers now run after the page actions, and only
if those left the page, the tab and the dialogs as they were; otherwise
they wait a frame. The focused field's editor text is taken before each
run, not once for both.
Tests: a_script_refused_before_it_runs_reports_the_realms_timers (js),
a_script_refused_before_it_runs_keeps_the_pages_timers (engine),
timers_wait_for_the_page_a_page_action_goes_to (ui). Each fails without
its fix.
- A page action or timer whose edits are refused shows nothing it did:
view changes go in only after apply_script_outcome accepts them.
- js_advance runs at most its 6,000-run cap; the last call only moves
the clock (Session::run_timers_at_most).
- Field callbacks see the document's layers (getOCGs), also after a
batch rebuilds the runner.
- The viewer's hit test uses what scripts show (shown_by_scripts): a
frame they showed takes presses, one they hid doesn't.
- Print previews draw without the live annotation and appearance
overrides.
- An appearance override naming no form (a cleared icon) draws nothing
instead of the button's own face (hayro patch 105).
- Page actions and timers wait while a dialog or prompt is open.
- A timer editing the focused field refreshes its untouched draft.
* fix(redact): apply marks with an indirect /Subtype and judge inline annotations
A Redact annotation whose /Subtype is an indirect reference was passed
over by apply although the audit, which resolves it, reports it as a
mark: the other marks applied and the content under that one stayed.
Annotation dictionaries written in place in /Annots were kept whatever
they covered, and an inline reply or pop-up kept a removed comment
reachable through /IRT or /Parent.
/Subtype is now resolved wherever redaction classifies an annotation.
Inline entries are judged by their rectangle and relationships like
referenced ones, on the marked page and across pages, and the pop-up an
annotation names (/Popup) goes with it.
* address review: an inline widget's /Parent field goes even when no referenced widget names it
* fmt
Widget.mouse_down records an /AA /D action, so a push button acting only on mouse down (the
LaTeX animate package's controls) is pressable and its press runs the action as Mouse Down
through the widget trigger dispatch, instead of saying it has no action.
Co-authored-by: Peter Yatsyshin <p.yatsyshin@googlemail.com>
* feat(js, forms): field setItems, setAction and a bounded toSource (#269)
Ported from #344 by @DWAA1660 onto the per-document script session.
- `field.setItems(items)` replaces a combo or list box's items (text, or
[text, export] pairs). The value stays when an item still has it, and
otherwise becomes the first item's. Written as the field's /Opt (pairs
only where the export value differs), /I dropped, field redrawn.
- `field.setAction(trigger, script)` installs a JavaScript action for one
trigger (MouseUp/Down/Enter/Exit, OnFocus/OnBlur, Keystroke, Validate,
Calculate, Format), replacing only that trigger's action.
- `Object.prototype.toSource()` writes plain data (strings, numbers,
booleans, null, undefined, arrays, objects) that eval reads back. It
refuses functions, cycles, more than 64 levels, 10,000 values or 1 MiB
with an error the script can catch.
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
* feat(engine): run a form's open, Will Save and Will Print actions and its widgets' pointer and focus scripts (#269)
Ported from #344 by @DWAA1660 onto the per-document script session: each
runs in the document's retained realm, so state the document scripts and
earlier events left is kept.
- The catalog /OpenAction's JavaScript (and its /Next chain, bounded and
cycle-safe; other action types skipped) runs once on opening, after the
document scripts, for background opens and reloads too, and again on
Revert in a fresh realm. Event Doc/Open.
- `Session::run_document_actions` runs /AA /WS (Will Save) or /AA /WP
(Will Print). A failing action is reported and never stops the save or
print. doc_save and doc_print run them first and report what they said
(`will_save`, `will_print`).
- Scripts that run unasked (open, Will Save, Will Print, pointer over,
focus) may only ask for highlight, zoom, focus or a page; a click's
Mouse Down / Mouse Up keep their requests.
- `Session::field_trigger` runs a widget's Mouse Enter, Mouse Exit, Mouse
Down, Mouse Up, Focus or Blur JavaScript (one undoable edit), reading only
that widget's dictionary. `field_focus` now runs an AcroForm widget's On
Focus / On Blur as well as XFA enter/exit.
- New tool `js_event`; form_focus's description says AcroForm focus
scripts run too.
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
* feat(ui): Will Save before saving, Will Print before printing, and widget pointer scripts in the app (#269, #247)
Ported from #344 by @DWAA1660.
- Save (both paths) runs the document's Will Save actions first, and Print
its Will Print actions; what they change is saved or printed, and what
they say is shown.
- The pointer moving onto or off a form widget queues its Mouse Enter /
Mouse Exit script, a press its Mouse Down. A click on a check box or radio
button queues its Mouse Up after the toggle, so the script reads the value
the click stored (#247). The queue runs after the frame's edit.
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
* docs(parity): document actions and widget triggers are partly supported (#269)
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
* test: check toSource's text (scripts can't eval it) and the trigger event names (#269)
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
* test: a check box's Mouse Up reads its new value; keep the fixtures on screen and the realm across an in-place save (#247, #269)
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
* fix(engine, forms, js, ui): review fixes for document actions and field triggers
- widget trigger actions follow /Next, each action its own edit; errors don't stop the chain
- setAction installs on the field the object stands for when names repeat
- toSource refuses non-plain objects and a __proto__ key
- doc_print checks the destination before Will Print; js_event returns only its own output
- combo/list clicks run Mouse Up; a refused click's Mouse Up doesn't run
- label crops are re-read when Will Print changed the document
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
* forms: run a pointer trigger on the field it hit and refresh the edited field's draft
A trigger now carries the field's object, so of fields sharing a name the one under the
pointer runs its own script (Session::field_trigger_target); js_event passes the field it
resolved. A trigger script changing the field being edited shows in its editor unless the
user already typed there, as focus scripts do.
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
* forms: carry the field's identity through focus scripts and js_event
Focus transitions now track the field object, so moving between fields sharing a name is a
move and the field that gained or lost focus runs its own script
(Session::field_focus_target). js_event takes an optional field_id from form_fields to pick
one of fields sharing a name.
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
---------
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
show_page runs a page's close/open actions, run_timers fires due timers (at most 16 a call),
next_timer and script_clock drive the viewer. Fields they show, hide or give another icon
are drawn so on screen only (screen_config); values and resets are undoable edits. A timer's
alerts and requests go to the console. getOCGs layer changes turn layers as the panel does.
Co-authored-by: Peter Yatsyshin <p.yatsyshin@googlemail.com>
## What this changes
**File ▸ Open opens Word, Excel, PowerPoint, OpenDocument and RTF files** as new, unsaved PDFs (`Report.docx` → `Report.pdf`), the "LibreOffice sidecar" that `docs/gaps.md` (gap 8) and `docs/file-format-parity.md` planned for `create.from-office`.
- **Conversion** (`crates/create/src/office.rs`): an installed LibreOffice runs headless as a separate process, in a private temporary folder (the input is always `document.<ext>`, so the user's file name never reaches its command line). It gets a fresh profile with `DisableMacrosExecution`, `MacroSecurityLevel` 3 and `BlockUntrustedRefererLinks` set, and Writer's and Calc's `Content/Update/Link` set to *Never* (2 for Writer and 1 for Calc: the two number their values differently), no console window on Windows (`CREATE_NO_WINDOW`), and is killed after 120 s. The output is size-capped (512 MB), must start `%PDF-`, and must open as a PDF before it is used. LibreOffice is found through `PDFCRAFT_SOFFICE`, the `PATH`, then the usual install folders per OS. Without it, the user is told it's needed rather than "not a readable PDF".
- **Off the frame thread:** File ▸ Open converts on the existing open worker (`ResultKind::Converted`); the frame thread only opens the result.
- **Detection:** `SourceKind::Office` requires an Office extension *and* an OLE, ZIP or RTF signature, so `photos.zip`, or a PDF saved with a `.docx` name, are not misread.
- **Open dialog:** PDF (still the default), Images, Office documents, Text, All files. All files is left out on macOS, where rfd merges every filter into one allowed-types list, and on the web; Office documents are left out on the web. The extension lists are now shared constants in `pdfcraft-create` (`IMAGE_EXTENSIONS`, `TEXT_EXTENSIONS`, `OFFICE_EXTENSIONS`), used by both detection and the picker. The duplicate filter list in `open_dialog` is gone. The two new labels are in all 22 catalogues, with their `ATTRIBUTION.toml` hashes updated.
- **Automation:** `doc_create_multiple`, `doc_combine` and batch `convert` go through `convert_to_pdf`, so they take Office documents too; their descriptions say so.
**Deliberately not in this PR:** Insert pages, Combine files and Create ▸ Multiple files convert on the frame thread, so they now refuse Office documents with a pointer to File ▸ Open (`convert_to_pdf_inline` / `as_pdf_inline`) rather than freezing the window for seconds. Moving them onto a worker is the follow-up. `create.from-office` goes planned → **partial**, with that and the rest listed in its notes.
## How you verified it
On Windows 11 Pro with LibreOffice 26.8.1 installed, per crate:
- [x] `cargo fmt --check`
- [x] `cargo clippy -p pdfcraft-create -p pdfcraft-engine -p pdfcraft-ui-egui -p pdfcraft-automation -j 2 --all-targets -- -D warnings`
- [x] `cargo test -p pdfcraft-create -p pdfcraft-engine --lib` (42 and 276 pass); `-p pdfcraft-automation --test automation` (the combine/create tests); `-p pdfcraft-ui-egui --lib open_worker` and `--test editing --test create`
- [x] `cargo xtask layers`, `cargo xtask assets`, `cargo xtask parity --partial`
- [ ] wasm check: the `wasm32-unknown-unknown` target isn't installed here, so I'm leaving it to CI. The web paths are `cfg`'d and were reviewed by eye.
**Tests seen failing before the fix**, by reverting each part:
- Detection off → `office_documents_are_their_own_kind`, `source_kind_tells_pdfs_images_and_text_apart`, and both engine Office tests fail.
- Worker conversion off → `file_open_converts_an_office_document_on_the_worker` fails with the old user-facing message, *"the file is not a readable PDF: no readable page tree or cross-reference data was found"*.
New tests also cover a converter that hangs (it's stopped; the test binary stands in for LibreOffice), one that writes no PDF, a missing converter, temp-folder cleanup, `file://` URL escaping (spaces, drive letters, UTF-8), and the search order.
**Real conversions, end to end:** `crates/automation/tests/it/office.rs` builds a minimal DOCX, an XLSX (with a formula, `=6*7`), a PPTX and an RTF inside the test, so no binary fixtures are committed. It runs them through `doc_create_multiple` in `separate` mode (each output reopened and its text checked, including `42`) and in `combine` mode (page order and bookmarks), and through `doc_combine` beside a PDF, with a page range on one file. These tests, `libreoffice_converts_a_document_when_installed` and the process-tree cleanup test (5 out of 5 reruns) pass with LibreOffice 26.8.1. The Office tests skip where LibreOffice isn't installed, which includes CI.
Three existing tests used a 4-byte `report.docx` as their "file that can't be converted" example. That's no longer true, so they now use `archive.zip`, which keeps what they test.
## Never-crash
- [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests
- [x] No input-derived indexing or slicing (`rsplit_once` for the extension); the PDF read back is size-capped
- [x] No `unsafe`. The Windows flag uses `std::os::windows::process::CommandExt`
- [x] A crashing, hanging or misbehaving LibreOffice is an error, never a panic or a stuck app: the worker's `catch_unwind`, a 120 s kill, and output checks
## Provenance
- [x] No Adobe assets; no fonts; no copied code
- [x] LibreOffice (MPL-2.0) is only *run* as an external process, as the docs planned; nothing of it is linked or copied. Its command-line options and the profile keys come from LibreOffice's public documentation.
- [x] The translations are contributor-original, written from the plain meaning of "Office documents" and "All files" ("Office" kept as a product name); hashes updated, `cargo xtask assets` passes
## Security
- [x] `--root` confinement unchanged: automation still resolves and reads paths before converting; only the private temp folder is written outside it
- [x] MCP and the control channel untouched
- [ ] **No new outbound network: please decide.** PdfCraft itself opens no connection. The profile blocks untrusted referer links and sets Writer and Calc never to update linked content on load. I converted eight synthetic files that link to a listener on 127.0.0.1: RTF `INCLUDETEXT` and `INCLUDEPICTURE`, a DOCX external picture, an ODT linked section and linked picture, an ODS table source and an external cell reference, and an ODP linked picture. None made a connection, with or without the link settings. Two things remain open: Impress has no link-update setting, and the process's network access isn't sandboxed (documented in `crates/create/README.md` and `docs/gaps.md`).
## Parity and reach
- [x] `create.from-office` planned → partial, with the evidence tests and what's still missing
- [x] Reachable headlessly (`doc_create_multiple`, `doc_combine`, batch `convert`)
- [x] Docs: `docs/file-format-parity.md` (Word/Excel/PowerPoint and RTF rows), `docs/gaps.md` gap 8, `docs/target-app-parity.md` row J (numbers not re-measured), `docs/roadmap.md` M10, the `ROADMAP.md` log, and the `crates/create` README
## Anything a reviewer should look at first
1. **The network question** above. It's the owner's call.
2. **The frame-thread restraint:** Insert, Combine and Create Multiple refuse Office documents rather than block. Is a worker for those wanted in this PR, or as a follow-up?
Ported from #4751 by @pyatsysh. DocInfo carries the layers; a run reports
the layers its script turned in Outcome::layers, for the viewer to show or
hide as the Layers panel does (the document isn't changed).
Co-authored-by: Peter Yatsyshin <p.yatsyshin@googlemail.com>
Ported from #4751 by @pyatsysh onto #5699's per-document realm.
`app.setInterval` and `app.setTimeOut` (an expression or a function) are
kept by the retained realm and reported in every Outcome (`timers`); the
caller owns the clock and fires one by running a Job with `timer` set, in
the realm that set it. `clearInterval`/`clearTimeOut` forget one. Bounds
kept from #4751: at most 64 timers per document, periods of at least
10 ms. A one-off run keeps none (the functions return an inert object, as
before), and changed document scripts start a realm without the old
timers.
Co-authored-by: Peter Yatsyshin <p.yatsyshin@googlemail.com>
## What this changes
Adds `Permissions::extract_pages()`: `assemble()` **and** copying (bit 5), as #5081 asks, since extracted pages carry their text and graphics into a file without the source's protection. It is used for Extract, Split, Split by size and every Combine/Insert/Replace/paste source check (`sources::source_document`). Changes within the document (rotate, delete, move, insert) keep `assemble()`.
Since this PR was opened, #6309 made `assemble()` revision-aware on `main`: bit 11 alone at revision 3 and later, bit 4 at revision 2. That already refuses "Any except extracting pages" at revision 3+. This PR builds on it:
- At revision 2, `assemble()` reads bit 4, which "Any except extracting pages" sets, so such a file could still be extracted and split into unprotected copies. Requiring copying closes that.
- New `Needs::PageExtraction` greys out Extract and Split up front. It reuses the existing security-refusal string, so no catalogue changes.
- `Document::allows_page_extraction()`, and an `extract_pages` key in the automation security JSON.
- `page_extract` with `separate: true` extracts and writes one page at a time (`extract_separate`), so it no longer buffers every page. The first page is extracted before `out_dir` is created, so a refused extraction leaves no empty directory. If a later page fails, the files this call wrote are removed, and `out_dir` too if this call created it (review on this PR).
- Insert Pages checks a PDF through the same path as the insert itself: new `Session::insert_source(dest, name, bytes)`, which the UI's insert now calls (review on this PR).
- The `organize.combine` parity notes now say "page extraction".
Fixes#5081
## How you verified it
`cargo fmt --all` is clean. Regression tests (adopted from Harrison-Booth's review patch, plus CodeRabbit's follow-ups):
- `crates/crypt/src/tests.rs::page_extraction_needs_assembly_and_copying`: unit test for `extract_pages()`.
- `crates/engine/src/tests.rs::extraction_permissions_cover_revision_three_to_six_and_leave_no_partial_output`: AnyExceptExtract on RC4-128, AES-128 and AES-256 refuses Extract, Split, Split by size and Combine/Insert/Replace sources without the permissions password, leaves source and destination untouched, and the owner can do everything. The authorized combine output has 2 pages, and a differently encrypted or unprotected destination is refused.
- `crates/engine/src/tests.rs::page_extraction_needs_the_copy_permission_on_every_revision`: a file that withholds only copying (R2 RC4-40 and AES-256) refuses extraction; the owner password lifts it.
- `crates/engine/src/tests.rs::pages_under_the_same_protection_are_inserted_without_page_extraction`: a separately built file under matching protection is inserted, and the result stays restricted.
- `crates/automation/tests/it/automation.rs::refused_extraction_writes_no_split_or_page_output`, and the `extract_pages` checks in `protecting_through_tools`, which now cover three presets, including a user (not the owner) for whom extraction is allowed.
**Only the crypt unit test has been run locally, and it passes.** The engine and automation tests timed out while compiling on this machine and have not been run, nor seen failing without the fix. The one `pdfcraft-ui-egui` call-site change has not been compiled. CI is the first place these build.
- [x] `cargo fmt --check`
- [ ] clippy with `-D warnings` on the crates I changed (not re-run after the rebase)
- [ ] tests pass on the crates I changed (crypt only)
## Anything a reviewer should look at first
- **Rebased onto #6309.** The tests no longer expect "Any except extracting pages" to allow assembly at revision 3+. The same-protection paste tests now use the "Inserting, deleting and rotating pages" preset without copying.
- **Copy (bit 5) is now required**, as the issue suggests. `crates/ui-egui/tests/it/editing.rs::protected_files_that_can_be_combined_are_flagged` used a copy-withheld file as "protected but combinable"; it now uses a print-withheld file, since a no-copy file is no longer a Combine source without the permissions password.
- **Same-document paste**: Paste re-opens the document's own bytes as an Insert source, which the new check would refuse. `sources::open_source_into` accepts a source that withholds extraction but allows assembly only when the destination will be saved under exactly the same protection (file key and `/Encrypt` values), so cut/copy and paste within one protected document still works. Pasting into an unprotected or differently protected document is refused. One limit: after changing security in the same session without saving, a cut can't be pasted back until you save.
- **Not in this PR:** CodeRabbit's `disabled_reason` suggestion for read-only documents predates this change and covers other commands too, so it belongs in a separate UI change.
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
- **New Features**
- Page extraction and splitting now follow a dedicated permission check, distinct from page assembly.
- Pages can be inserted from certain protected PDFs when their protection settings match the destination document.
- **Bug Fixes**
- Separate-page extraction now cleans up files created during a failed operation and avoids creating an output directory when the first page cannot be extracted.
- Security details now report whether page extraction is permitted.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->