The decoders leave /Crypt out wherever it stands (Stream::filters), so
a chain of /Crypt and eight other filters decodes, but with_direct_parms
counted nine and left its parameters as they were: a predictor given by
reference was then skipped. Only the other filters count now, and a
/Crypt entry is null in the copy, as no decoder reads it. Tests cover
that chain and one exactly as long as a decoder takes.
From reviews with the pdfcraft-review skill.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Document::with_direct_parms resolved an indirect /DecodeParms and its
array entries but not the numbers inside (/Predictor 9 0 R), which
Dict::int doesn't follow. A 1-bit image with such a predictor was
decoded without it, its rows shifted by their tag bytes, and Group 4 of
that stored in its place; resampling read images the same way. It now
keeps, of each parameters dictionary, the numbers and flags the decoders
read, resolved, and /JBIG2Globals as a reference. Only those small
values are copied (an entry that is no dictionary is null), so a file
naming one big object from every key and filter can't have it copied a
hundred times.
The change is #4384's, moved down so that every PR stacked on this one
reads such parameters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Persian came to main after these rows were written, so fa.tsv had neither "Monochrome Images"
nor "CCITT Group 4". They go beside "Grayscale Images", worded like the Arabic and Kurdish rows,
with the Persian digit the catalogue uses elsewhere ("AES ۱۲۸-بیتی").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
Stream::decoded reads only direct parameter dictionaries, so an image
whose /DecodeParms is a reference (whole, or as array entries) was
decoded without its predictor. For a 1-bit image with a PNG predictor
the rows came out shifted by their tag bytes, Group 4 of that was still
smaller, and the picture was replaced by a skewed one; resampling and
merging image strips read the same way.
pdfcraft_cos::Document::with_direct_parms gives the stream with the
parameters its filters use made direct: only the entries at the
filters' places, and none for a chain longer than any decoder takes,
so at most MAX_CHAIN objects are copied. The optimizer's pixels() and
the Group 4 step decode through it.
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
With Add text the page's own paragraphs took input only while the pointer was over one
(over_source), and their drags end in edit_text_ui::page_input. A paragraph dragged off its box
was left hanging after the release (block_drag), to land wherever the pointer next met a
paragraph; one flicked off in its first move never started, so nothing moved. main has the same
test. Add text now also acts on the paragraph the page router says holds the press, until the
button is up, so the drag ends where it is released, as with Edit text, and Add text's
new-box path does not take the press over.
New test add_text_finishes_a_paragraph_drag_that_leaves_its_box, seen failing before the fix
(the drag still pending after the release); the flick was checked by hand the same way. From
CodeRabbit's third review of #5300.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A click acted on what its press was on wherever the button came up: a press on a link, a push
button, a signature field or a list box that slipped off it before the release (still a click to
egui, within 6 px) followed the link, ran the button's action, opened signing or picked a list
box's option. On main a click went to what lay under its release. Now it needs both: the
link's or the fields' hit test at the release point must give what the press was on, as a button
pressed and released off itself does nothing. Check boxes and radio buttons already did so, by
their overlay's click-outside test.
New tests a_press_on_a_link_released_off_it_follows_nothing and
a_press_on_a_button_released_off_it_runs_nothing, seen failing with this commit's source
reverted; the check box test shares their slipping_click helper. From CodeRabbit's second review
of #5300.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With Add text, the page router's hits kept only the paragraphs' bodies (3d14ffd7), so a click
just inside a closed paragraph's left or right edge, within the edge's grip, had no owner: it
neither opened the paragraph (over_source counts the point as the paragraph's) nor started a new
box. Add text now keeps a paragraph's hit wherever the point lies in a paragraph's box, edges
included, by the same test as over_source (edit_text_ui::over_paragraph), and drops it only
beside the box, where a click starts a new box. The edge's grip shows there again, and a drag
from it rewraps the paragraph, as on main.
New test add_text_opens_a_paragraph_from_just_inside_its_edge, seen failing with this commit's
source reverted. From CodeRabbit's second review of #5300.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review is right that `Stack::push` always returned `Some(())`, so `overflowed`
never became true and the whole-instruction discard was dead code.
Making it live breaks the page. `an_operator_with_hundreds_of_operands_does_
not_end_the_content` (#4260) puts 300 stray operands before `0 1 0 rg` and
asserts the `rg` still reads its own three: with the discard alive the fill
stays the previous red, measured as `left: [255, 0, 0]` against
`right: [0, 255, 0]`.
The ring buffer already gives the right answer. It keeps the newest operands,
which are the ones nearest the operator -- its own -- and no operator takes
anywhere near 256 of them: `scn` takes one per colourant and ISO 32000 allows a
DeviceN space 32. So the strays fall off the back and the operator is applied to
its own operands, which is what Acrobat and Chromium do. Dropping the
instruction would lose content that is there to be read.
The dead branch and the flag are gone, `push` no longer returns a result it
cannot produce, and a test pins what the buffer does: the strays are `1` and the
operator's own operand is `0`, so the two answers differ. Keeping the oldest
operands instead fails it at 256 strays.
With Add text, the page router's hits included Edit text's pictures and paragraph edges, which
nothing acts on with Add text, and Add text starts a box only where nobody owns the press: on a
scanned page (a picture filling it) no click placed a box, a click within a paragraph's edge
grip beside it did nothing, and the cursor over a picture was the hand. Add text now has only
the paragraphs' bodies, over their text, where it finishes the box being typed (over_source).
New test add_text_places_a_box_on_a_picture_and_beside_a_paragraph, seen failing on the
previous commit. From CodeRabbit's review of #5300.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A delete moves the panel's inline editor along with its comment or reply,
but Undo and Redo cleared only the selection. Either can put annotations
back or take them away, so the editor stayed keyed to an index that could
now be another reply: with B's editor open, delete reply A, Undo, and Save
wrote B's draft over A. history_step now closes the editor (and its
unsaved text) with the selection. Found by FredApps in review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
From a review of this branch:
- Escape mid-drag with a text markup tool (Highlight, Underline, Strikeout, Squiggly, Replace
Text) or Redact over text left the half-made selection on the page: the cancel took the Escape
that would have cleared it. It is dropped now. The marks made from a selection at its end
(after the page tools, which the release blocking did not reach) also wait for a cancelled
press's button; egui ends its own drag on Escape without a drag stop, so they were not made
in practice, but nothing but egui stopped them.
- The open paragraph's frame took the press above and below the text only: its sides still gave
edge grips (a closed paragraph's rewrap handles) on the Editor layer, over the frame. Edges now
belong to closed paragraphs only.
- A press in the frame right after a release kept the last press's owner until a page claimed
it, so a drag from the margin beside a page could move the comment just clicked. A new press
now drops the last owner before the pages give it theirs.
- A release and a new press in one frame no longer leave the new press cancelled.
- Added content is picked up with the tools that act on it (Select, Add text, Edit text), not with
every tool while Edit a PDF is open.
- The layer docs in page_cursor.rs say what lies on each layer now.
New tests an_open_paragraphs_frame_takes_the_box_beside_its_text and
a_drag_from_beside_the_page_right_after_a_click_leaves_the_comment_alone (the release and the
next press in one frame) fail on the previous commit. escape_cancels_a_highlight_being_dragged_over_text
(with a control: the drag highlights without Escape) passes there too, for the reason above, and
stays as a guard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Edit text's paragraph edges were tested twice: by edit_text_ui for the cursor, on the page's
paragraphs, and by object_ui for the press, on its own object inventory (grabs_edge). Where the
two inventories disagree, the resize arrow shows and a press starts an invisible area selection
instead (#2276, the review's step-3 probe). The open paragraph's frame showed the Move cursor
4 px out and took the press 6 px out.
Each of these parts now has one hit test, in the page router's table, and acts only on the
press it was captured on:
- edit_text_ui::hit: a paragraph's edges (Handle), its body (Text; Editor for the open one).
- edit_text_ui::picture_hit and picture_handle_hit: a picture's body on the Text layer, asked
before the paragraphs, so text on top of artwork takes the press; the selected picture's
corners and rotate knob on Handle, asked after them, so they win over text.
- object_ui::hit: the objects selected together (Placed) and the open paragraph's frame
(Handle), with one reach (FRAME_REACH, 6 px) for the cursor and the press. An area selection
starts only where nobody's hit claims the press.
- content_ui::hit: added text and pictures (Placed; the selected one's corners on Handle), over
form fields; Add text starts a new box only where nobody's hit claims the press.
image_owns_pointer and content_ui::select_grabs go, with the edge test object_ui kept a copy of
(grabs_edge). The page's hits move into canvas::PageHits.
Un-ignores a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor: it passes on main
already (the inventories agree for its paragraph since the probe was written), so it stays as a
guard rather than as proof. New test an_open_paragraphs_frame_shows_move_wherever_a_press_takes_the_box
fails on the previous commit (the cursor is Default there).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Forms: forms_ui::hit is the fields' only hit test (cursor and press). The press opens a text
field (#2213) and its click acts on the field the press was on, from the page router, instead
of the field under the release point: a press on one check box released over the one beside it
ticked the second. Now it ticks neither, as a button pressed and released off itself does
nothing. Forms no longer wait on comments::select_grabs: a comment that moves (a note, a shape,
a signature, a Fill & Sign mark) hits on the Placed layer, over form fields, so it still takes a
press over a field (a signature can be dragged off the field it covers), and text markup stays
on the Comment layer, under them, so a click on a box under a highlight ticks the box.
select_grabs goes.
Prepare Form: prepare::hit (the selected field's corner handles, then a field, on Placed so a
field being moved stays over the comments as before) gives the cursor, and the drag, click and
double click act on what the press was on; a press held by something else is not Prepare's.
New tests: a_press_on_one_check_box_released_over_another_ticks_neither (fails without this
commit: the second box is ticked) and a_check_box_under_a_highlight_takes_the_click (fails with
markup on the Placed layer).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Sign area, marquee zoom and snapshot, crop and redaction boxes, and the stamps, act where
nothing drawn over the page holds the press (the page router has no owner for it), rather than
wherever the press lands on the page. Fill & Sign already asks the router (fill_grabs).
Their drags start on the primary button and end with its release, however egui reported it:
its drag stop, or the button found up. They started on any button, so a right-drag drew a
signature rectangle and opened the signing dialog (and drew a crop or a marquee), and the end
waited for a pointer position, so a drag whose release came without one never ended.
New test a_right_drag_draws_no_signature_rectangle, seen failing with the drag started on any
button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page router (canvas/page_router.rs): a page module answers "what of mine is under this
point?" in one hit test, which gives a Hit (an owner, a layer, a cursor, a tip). On the frame of
a press, the hits at the press point decide who it belongs to, by PR 2's table (the highest
layer the tool reaches), and the router holds that capture until the button is up and its
release has been seen; automation's cancel and Escape drop it. The pointer's hits claim the
cursor and the tip, after the tools that are not converted yet, as before.
Comments: comments::hit is the module's only hit test, with each tool's filter (Select: point
handles, resize handles, then the comment; Fill & Sign: the same over its marks only; shape
tools: the selected comment's handles only). The Select tool's drag, click and double click act
on the comment and part the press was captured on, instead of testing the press origin and the
release point again; fill_grabs and select_grabs ask the router while the button is down. The
topmost comment is the one drawn last (/Annots order), not the last in inspection's top-edge
order: over stacked comments the tip named the top one while a click selected the one
underneath. Text markup declines the drag, so a click on a highlight selects it and a drag
across it still selects the text.
Links: the link hover is a hit (hand and target), and a click follows the link its press was
on. Text selection starts unless the press is held by something that keeps its drag (a link, a
comment that moves); it tested for a link under the pointer at the moment egui called the press
a drag, so a drag from text onto a link selected nothing.
Un-ignores a_click_on_stacked_comments_selects_the_one_drawn_on_top; new test
a_drag_from_text_onto_a_link_selects_the_text. Both fail with their fix reverted (inspection
order for the topmost comment; the link test at the pointer).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Escape cancelled a comment gesture, Prepare's marquee, measuring and the camera tool, each in its
own module; with the Sign area, crop, marquee zoom, a redaction box, the Link tool's rectangle,
a paragraph or picture drag, added content, an object move or a text selection it did nothing,
and the release then drew, placed or opened what the drag had begun (the step-4 probe: Escape
mid-rectangle still opened the signing dialog).
document_area now handles Escape once, before the pages: while the pages hold a press, it drops
every pointer gesture (DocView::cancel_pointer_gestures, as automation's cancel does) and marks
the press cancelled. Until the button is up the page tools take no input from it, so its
release neither clicks (a stamp, a Fill & Sign mark) nor ends a drag. Escape without a press
keeps its meanings (close the editor, clear the selection, back to the Select tool).
Un-ignores escape_cancels_a_signature_rectangle. Two new tests: Escape mid-press keeps the
release from placing a stamp, and Escape mid-drag abandons a crop rectangle. All three fail
with the cancel taken out; the stamp test also fails with the cancel kept and the release let
through.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four holes CodeRabbit found on 648dd428:
- alignment_frame framed a one-row paragraph when any alignment entry
was set, including one past the draft's rows, and then moved rows
with no alignment of their own to the column's edge. It now takes the
draft and frames only when every draft row is aligned, by its own
alignment or the paragraph's.
- remap could not tell which of two same paragraphs an edit took
("A\nA" to "A"), and kept the deleted one's alignment. remap_at takes
the caret where the edit ended, and the typing path (follow_typing,
both editors) passes it from the box's state. The caret only chooses
between readings that fit both texts.
- Both editors judged a focus loss by where a click landed, so focusing
a Format text control by keyboard or a screen reader (no pointer)
closed the box before the alignment was chosen. They now also judge
by where the keyboard went (focus_pos): focus outside the document
view, or on the added-text box's own buttons, keeps the box open as a
click there does.
Each change has a test seen failing before it.
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() already dropped the whole gap at a wrap point (#4199); rejoining it always guessed a single space back, so two or more typed spaces silently became one, and a Chinese/Japanese word split (which has no space of its own) could gain a spurious one. wrap() now records how many separator characters it consumed at each wrap (0 for a CJK split); the /PCSoft marker carries it as /N (a file saved before this, or by another writer, reads as 1); reflow_text and the mixed-style row editor insert exactly that many spaces back instead of assuming one.
craft_menu writes "⋯" (U+22EF) itself for the menu of titles that don't fit, through the app's own label callback. No font this app ships or falls back to has that glyph, and egui 0.37's MissingGlyphPolicy::Panic crashes on it as soon as the bar is narrow enough to need it: every editing test that narrows the window while a paragraph is open hit this. The ordinary ellipsis the rest of the app already uses everywhere else is available and reads the same.
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.
The paragraph-key helper has to carry reflow text, which a width change uses. ClosePath on an empty subpath is a match guard so the edit crate still passes clippy.
A partial rewrite put the paragraph marker around the shared ET, so the neighbour in that text object joined the paragraph and the next edit deleted it.
An unmarked paragraph was closing its marker at the following line's BT, which is after that line's BDC. The close now follows this paragraph's last ET.
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.
Two holes in alignment_frame:
- A column's right edge came from the runs that start at its left
edge, so a row of several styles ended at its first run, and the
column could fail to hold a title wider than that run. It now takes
the right edge of each complete row (row_rect).
- A row with no column to align in was framed by its own box, which
the rewrite also used as the width its text wraps at: changing an
isolated row's text together with an alignment wrapped it at its old
width, word by word. When the text changes and no column holds the
row, alignment_frame now returns None, so the row keeps the width it
has without the alignment. With the text unchanged the own box is
still the frame, so aligning twice is still a no-op.
alignment_frame measured a one-row paragraph's alignment in the frame
of the nearest overlapping block. A full-width heading closer to the
title than the title's own body column gave it a page-wide frame, so
centring or right-aligning the title moved it out of its column. It now
considers every overlapping block whose column holds the row and takes
the narrowest, the nearest on a tie, so a page-wide heading gives the
title no frame while a narrower column that holds it does. When no
column holds the row it is still measured in its own box.
The comment about page-wide headers named an exclusion the filter
never made; the doc comment now says what is chosen and why.
When Document::comment_layer gave nothing (a comment with nothing to draw, or
a document that can't be read), prepare_move asked again on every pointer
frame of the drag, and each time it copies and writes out the whole
document. The failure is now remembered for that drag (edit generation, page,
index); the next drag asks again.
A render that fails no longer drops the layer either: only its pixels go, so
the frame alone follows the pointer as before, the failed scale isn't waited
for, and another scale renders the same layer again without making it anew.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main now gives page modules one pointer per frame (#2685); the comment drawn where it goes
follows `cx.ptr.pos`, as its frame does, instead of egui's hover position.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A NoRotate comment on a quarter-turned page isn't drawn in its /Rect; its layer's tile and
its offset now come from the box it is shown in, as the frame it follows does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moving a rectangle, an oval or any other comment showed only its blue
frame going, the shape staying where it was until release. The comment is
now rendered alone once as the drag starts (Document::comment_layer: a
read-only copy with only that comment, no page content) and laid over the
page where it goes; the page beneath keeps showing it where it was until
the move is applied, as a CAD program's move does. Rendering the page
without it would cost a whole page's render at every drag on a heavy plan.
Stamps and Fill & Sign marks keep their own layers. annot: isolate_annotations
for several comments of a page, so a group can be drawn together.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eframe runs only App::logic while a window or browser tab is hidden, so a
?file= download that finished then opened the document without the titles
changing. The titles are now set at the end of logic as well as in ui.
Prepare's arrow nudge read the frame's Alt, so an Alt+arrow whose Alt was
let go in the same frame nudged the field instead. Each press's own
modifiers now decide, as for objects, pictures and comments.