Includes #4325#1952#4180#2529#4320#4805, preserving their original histories.
Keep paragraph alignment and hard/soft wrap metadata together; retain CCITT/downsampling/RGB ownership guards with bounded JBIG2 export and exact-copy symbol encoding.
Combined interaction regressions added. Validation pending.
Preserve caption editing and current form/Prepare module boundaries. Carry export-only appearance preservation, validation and data round-trip tests to their current paths, and retain current attribution metadata. Hosted CI must validate this refreshed head.
Use the unified Format Text panel and current page-input context instead of restoring old monolithic UI code. Preserve main's pointer routing, editor accessibility and preferences; retain colour, save and input regressions. Hosted CI must validate this refreshed head.
The test only checked each refusal's message. Without the tool's notation guard, set_props
refuses the same notation with the same message as a failed edit, so the test passed with the
guard removed. It now asserts InvalidArgs for every row, and fails on "a EVCALC b" without the
guard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A notation is kept in the script's /** BVCALC … EVCALC **/ comment and read
back from there. One holding EVCALC was stored cut short ("a EVCALC b"
read back as "a", and its JavaScript translation didn't parse), one
holding */ ended the comment, and an empty one became "event.value = ;".
form_set_props now refuses each (af::notation_storable: it has to read back
as itself). The test also pins a notation beside op alone and beside
fields alone (from the review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
A notation beside op and fields set the notation and dropped the rest, a
field name that wasn't a string was left out, a misspelt key was ignored,
and validate {"min": 0, "max": "100"} wrote a min-only range. Each is
now refused and nothing changes. A null bound, as form_fields shows the
one a range doesn't have, is no bound, so its output is taken back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
calculate_json wrote the lowercased AFSimple code, so a product or an
average came back as {"op": "prd"} or {"op": "avg"}, which
form_set_props refused as an unknown op. One name per operation now
serves both: calculate_arg looks names up in it (still taking minimum
and maximum), and the listings write it (from CodeRabbit's review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
calculate_arg read the notation first, so {"notation": …, "script": …}
set the notation and dropped the script unnoticed; the script check now
comes first (from CodeRabbit's review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
- CalculatedField carries the field object: form_calculation_order and
the Calculation order page find each row's formula and value by it,
so two calculated fields sharing a name (a malformed form) each show
their own.
- An AFSimple_Calculate list counts as written out only when it ends
the call: "row" + suffix and [...].concat(more) are names built at
run time, and an array item counts only when it ends at a comma or
the list's end, so ["row" + i] no longer reads, or renames, "row".
- After a reorder, Undo is offered on the missing-field card instead of
bringing back the all-clear beside it.
From CodeRabbit's review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
calculation_order flags MissingField (a field deleted from under a sum, a
typo in notation, a script's getField; a group's name and a widget's
number count as there) and BuiltNames (a getField or AFSimple_Calculate
list built at run time, found as af::Site::Built in the same pass as the
literal names). form_calculation_order gives each field's formula and the
two new issues; form_fields shows a custom Calculate script; the
Calculation order page explains both in its row tooltips.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
The batch step took Reduce File Size's monochrome settings unchanged, so
`ppi: 600` (or `--ppi 600`) kept colour and grey images at 600 ppi but
halved 600 ppi black-and-white scans to 300. Black-and-white images now
go to the larger of ppi and 300 ppi: a higher ppi keeps their
resolution, and a lower one doesn't take scanned text below 300. The
doc_batch description and docs/cli-batch.md say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PDF Optimizer and Reduce File Size downsampled colour and grey images
but not black-and-white ones. Settings::mono is now MonoSettings {
downsample, target_ppi, above_ppi, compression }, 300 ppi above 450 ppi
and CCITT Group 4 by default (Monochrome Images ▸ Downsample to;
doc_optimize mono: { downsample, ppi, above_ppi, compression }).
A 1-bit image or image mask (no filter, general-purpose filters, or CCITT
fax after any of them) and eight-bit grey of only black and white are averaged by
area: each new pixel takes the sample covering more of it, a tie 0, so a
dark line half a new pixel wide stays. They are stored as Group 4, or as
1-bit Flate when that is smaller; with Retain a resampled image keeps its
kind of compression. Images with a soft mask keep their size.
CCITT decoding moves from Export ▸ Images into pdfcraft-filters
(decode_ccitt, CcittParams, through hayro-ccitt), and
pdfcraft_cos::Document::decoded_image reads a fax image's /DecodeParms
for it (undoing filters before CCITT, such as ASCII85Decode, with any
/DecodeParms given by reference made direct first), so export
and the optimizer share one reader, and Group 4 scans can be
downsampled. A 600 ppi text page went from 79 KB to 40 KB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
Group 4 images (now written by Create PDF and the optimizer) would
otherwise stop exporting: they were reported as "can't be exported
yet". A CCITT fax image (its only filter) is decoded through
hayro-ccitt with its /DecodeParms (K, Columns, Rows, EndOfBlock,
EndOfLine, EncodedByteAlign, BlackIs1), masks included; /Columns has to
be the image's width, so a hostile value can't size the buffers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
With compatibility retain, doc_optimize reports the later of the
header's version and the catalog's /Version. It read /Version only when
it was direct, so a catalog saying 1.6 by reference was reported as the
header's 1.4. It is now resolved first, as the optimizer reads it
(a88c8afb). The bug was already on main; found in pre-push review as a
sibling of that fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
JBIG2 images, and JBIG2 stencil masks, were reported as "can't be exported
yet", so an image the Optimizer stores as JBIG2 would stop exporting. They
are now decoded with hayro-jbig2 (already in the tree through hayro), with
their /JBIG2Globals from a DecodeParms dictionary or array, as JBIG2Decode
gives them: black as 0 (ISO 32000-2 §7.4.7). Unreadable globals, a JBIG2
page of another width, or fewer rows than the image skip the image; rows
past its height are dropped, so the output is bounded by the checked size.
docs/pdf-spec-parity.md §7.4 no longer says there is no JBIG2 encoding.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
pdfcraft_filters::encode_jbig2_generic writes 1-bit rows as a JBIG2
stream for JBIG2Decode (embedded organisation: a page information
segment and an immediate lossless generic region, no globals): template
0 with its nominal AT pixels, typical prediction, and the MQ arithmetic
coder (mq.rs), written from the JBIG committee's ISO/IEC 14492 Final
Committee Draft. The coder reproduces its Annex H.2 test sequence byte
for byte; output round-trips through hayro-jbig2, and Poppler renders
it the same as the original.
The optimizer's Monochrome Images gets JBIG2 (lossless) beside CCITT
Group 4 (still the default), doc_optimize mono jbig2; a document below
PDF 1.4 gets /Version /1.4 in its catalog. On 300 dpi text JBIG2 is
about a third smaller than Group 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
A bare /Indexed, /I or /CalGray is no colour space on its own, so it can
only name a resource, which might be RGB: such an image is now left
alone instead of being read as one bit per pixel. doc_optimize refuses a
mono that isn't an object or a compression that isn't a known name,
instead of reading it as the default (from CodeRabbit's review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
pdfcraft_filters::encode_g4 encodes packed 1-bit rows as Group 4 (ITU-T
T.6, /K -1) through the fax crate (MIT, already in the tree via tiff),
with rows capped at 32,767 samples so its 16-bit positions can't
overflow; round trips are checked against hayro-ccitt.
The optimizer (Settings::mono, CCITT G4 by default, so Reduce File Size
too) re-encodes a 1-bit image of one colour component, or an image
mask, stored with no filter or general-purpose ones, when that is
smaller as stored. The samples decode to the same bits, so colour
space, decode array and masks stay. PDF Optimizer gets a Monochrome
Images row and doc_optimize a mono option. optimize.mono-compression
goes from planned to partial (JBIG2 and monochrome downsampling not
yet).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
No conflicts. main's changes to the files this branch touches (the page
cursor router in canvas.rs, hover tips in tools/comments, new tools and
document methods) don't meet this branch's hunks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wrap's space count has to survive an incremental save and a full rewrite, and a width-only text_edit has to reflow with that count. A recorded editor session narrows the box and widens it again.
wrap()'s own test for mixed exotic whitespace fed through it directly, bypassing the caller's CR/CRLF normalization, needed its expected value updated for #4199's keep-every-space design, landed on main after this branch's base.
Editing either end of one text object, then saving and editing again, has to leave the other paragraph in place. The library tests did not drive text_edit and doc_save.
Three or more rows at one repeated step stay one paragraph when the file has no TL, which is how LibreOffice writes each line. A rewrite that leaves a source row still marks the whole paragraph, so the neighbour survives a second edit. Dragging the box width keeps the line breaks.
The join limit follows the text object's TL when it has one, checked against the measured baseline gap. A rewrite of every row is marked so a new row one leading above the next paragraph does not absorb it.
A gap of 2.4 times the font size was still under the 2.5 times join limit, so two paragraphs became one and editing the first deleted the second. Once a paragraph has two rows, a wider gap than its own leading stays the next paragraph.
Resolves test-file conflicts where main and this branch both appended tests:
main's file plus this branch's appended tests, unchanged.
fmt, clippy -p pdfcraft-forms -p pdfcraft-automation, and both crates' tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Resolves test-file conflicts where main and this branch both appended tests:
main's file plus this branch's appended tests, unchanged.
fmt, clippy -p pdfcraft-forms -p pdfcraft-automation, and both crates' tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Resolves test-file conflicts where main and this branch both appended tests:
main's file plus this branch's appended tests, unchanged.
fmt, clippy -p pdfcraft-forms -p pdfcraft-automation, and both crates' tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
* M6: Organize pages ▸ Page properties sets the selected pages' tab order
Page properties in the Organize pages panel was listed as planned. In
the app, a page's tab order could only be set for every page at once,
with the form.tab_order commands, which offer no Unspecified. (The
form_tab_order tool already set chosen pages, Unspecified included.)
- Page properties (page.properties) opens a Tab Order dialog for the
selected pages, with Use Row Order, Use Column Order, Use Document
Structure and Unspecified. OK writes Edit::SetTabOrder for those pages
as one undo step. The dialog opens on the pages' order, or with no
choice made when they differ, and then OK is off until one is chosen.
OK on the order the pages already have adds no undo step.
- Rows and columns run right to left in a document bound on the right,
as Use Row Order and Use Column Order are described. PdfCraft reads a
right-to-left binding from /ViewerPreferences /Direction /R2L
(ISO 32000-1, 12.2, Table 150). Before, rank_tabs ranked them left to
right.
- A page whose /Tabs is an indirect object now takes its row, column or
structure order. Before, rank_tabs read only a direct name and ranked
the page in annotation order.
- pdfcraft_forms::tab_orders and Document::tab_orders read each page's
/Tabs. form_tab_order returns them as page_tabs, and reads without
changing anything when it is given no order or field.
- set_option("dialog", "page-properties") opens the dialog.
- Parity: organize.page-tab-order is shipped, and its notes say the
Actions tab of Page Properties (organize.page-actions) is still
planned; the Organize pages grid notes list Page properties.
* Page properties: OK applies only to the document the dialog was opened on
The draft keeps the document's id. When another document is active by
OK, the dialog closes without changing either document.
* Menus: list Page properties… in the Pages menu
The menu bar now lists its commands explicitly, and every command with a menu group must appear in it.
* Page properties: apply the tab order only to an unchanged document
An edit, undo or redo made while the dialog is open can move the pages
at the stored indices. OK now closes the dialog without changing a page
when the document's edit generation differs from when it opened.
* Page properties: say when a document change cancels the tab order
OK shows a notice when the document changed or another document became
active while the dialog was open, so the user knows to open it again.
* M6: form script messages keep their title, icon and buttons, and every one is shown (#4293, #4294)
* M6: a form script message waits for a notice the toast already shows
* M6: form script messages past the queue's room are counted and reported
* M6: a sentence break in the merged parity note
* M6: an object that names no parameter is a message's text, as a String object or a caught error
* M6: the count of left-out script messages is said again if another notice replaces it before its time
* M6: a full stop between two sentences of the FormCalc parity note
* chore: re-run CI (a frame-budget timing test missed on a shared runner)
main split comments.rs into tools/comments/: annotation_deleted,
after_delete and on_page go to state.rs, their test to tests.rs. main
pulled the post-edit selection into select_after_edit, which now takes the
deleted page's annotations from before the delete. ROADMAP.md and
docs/roadmap.md keep both entries, main's newer one first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main split and refactored the text-rewrite code this PR changes
(automation dispatch.rs, edit added/ and text/, text_regions/styled.rs).
Conflicts in added/store.rs, text/rewrite.rs and text_regions/styled.rs
take main's structure, with per-row alignment applied in it:
- rewrite.rs: LineWriter keeps the paragraph's alignment and the rows'
own (align, aligns); each line looks up its row's alignment
(align_of) for justify and for its offset. Alignment::offset takes the
row's alignment. Wrapped lines carry their row, as wrap returns it.
- styled.rs: wrap_styled_rows also returns each row's draft line
(row_lines), which the kept-rows check counts aligns by.
- Imports for the moved modules (alignment_frame, BlockStyle,
align_code, align_from, content in dispatch.rs).