Commit Graph
8 Commits
Author SHA1 Message Date
29e978d6d8 Re-land #264, #263, #270, #271, #267, #280: clipboard pane, table styles, Manage Styles, Asian Typography, Group, Shape Effects (#323)
The original squash merges of these six PRs were empty. Integrated and tested together on current main.

Co-authored-by: Valentin Klein <vangel1976@googlemail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:09:04 -04:00
c0bbcbac24 Charts and SmartArt: draw them instead of dropping them (#292)
A chart or SmartArt diagram in a DOCX was dropped on import: it took no
room, so the text after it moved up (five of the six chart documents in
docxide-pdf's fixtures came out a page short), and nothing was drawn.

They are now an InlineObject::Graphic: it takes its room, floats and
wraps like a picture, and draws a list of neutral items (shapes, paths,
one-line texts, pictures) that layout scales to the object's box.

- Charts (read/chart.rs) are drawn from the values the chart part
  caches: clustered, stacked and 100% bar and column charts, line, area,
  pie, doughnut and scatter charts, with title, legend, gridlines, axis
  labels and Excel number and date formats.
- SmartArt (read/diagram.rs) is drawn from the drawing Word stores
  beside the diagram: preset shapes, theme colours, text and pictures.
- Theme colour handling is shared (read/drawing_color.rs).
- Each chart or diagram part is read once, only through a relationship
  of its type, under a per-file budget of items.
- Tagged PDF tags them as figures with their alternative text, and the
  accessibility check reports charts without one.

Not yet: charts and diagrams aren't written back when saving a DOCX
(they weren't before either), so the editor doesn't let you move or
resize them. Radar, stock, 3-D and combination charts draw their first
supported chart type only; SmartArt pictures aren't clipped to their
shape.

docxide-pdf bench (365 documents, vs main + #135): page counts right
320 -> 326 (all five chart documents and one SmartArt document), Jaccard
passes 208 -> 215, SSIM passes 76 -> 79; eight documents better, none
worse.

Co-authored-by: Oliver Zieschang <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 14:39:41 -04:00
Valentin KleinandClaude Opus 5.5 68364f75c8 Tests: shapes and pictures can be clicked, resized, dragged and nudged (#228)
Fixed by #46 but only text boxes were covered. These walk the editor's
path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test,
select, resize with arrange.bounds, drag to float with Square wrap,
nudge, undo. Covers #142 and #82.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:17:06 -04:00
e4456efb84 Text boxes and floating objects: editing, drag/resize, wrapping, overflow, footnotes (#46)
* fix text boxes: type into a new box, click in and out, Escape leaves it

Insert › Text Box left the caret in the body after the box and clicks inside a
box never reached its story, so its text could not be edited.

- insert.textBox puts the caret in the new box (when anchored in a top-level
  body paragraph, the only place layout shows box text today).
- Layout emits a hit-test-only Placed::TextBox area; story_at prefers a box
  under the point (body text wins over boxes behind it) and ignores box lines
  outside their box.
- Canvas: click enters a box, clicking outside goes back to the body (not while
  editing a header/footer); double-click in a box selects a word there.
- Escape in a box puts the caret just after it (Document::text_box_anchor).
- Editing a box doesn't dim the body or show "Editing header/footer".
- Tests: engine (caret in box, typing, undo, table cell unchanged), layout
  (story_at inside/outside/behind), doc (anchor lookup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix pasted text boxes sharing the original's text

A copied fragment only carried the text box's part id, so a pasted box showed
the original's story: editing either changed both, edits made after copying
leaked into the paste, and pasting into another document (or Insert › Text
from File) pointed at whatever part had that id there, e.g. a header.

- Fragment carries copies of the text box stories it shows (nested boxes too).
- insert_fragment gives every pasted box a new story of its own; a box whose
  story can't be found keeps none rather than showing an unrelated part.
  Nesting is depth-limited and one paste creates at most 4096 stories.
- Insert › Text from File uses Document::body_fragment, which brings the
  file's text boxes along.
- Tests: doc (own story, copy-time snapshot, other document with clashing
  ids, box in a table, fragment without parts, self-nested box), engine
  (copy/paste/type/undo through commands).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* word count includes text boxes, footnotes and endnotes (Word's default)

The status bar and Word Count dialog counted only the body, so words typed
into text boxes (and notes) were missing.

- Document::counted_stories / word_count_including_notes: the body plus the
  text boxes, footnotes and endnotes it actually shows (nested too); orphaned
  stories (deleted or cut boxes), headers, footers and comments don't count.
- Session::count_notes (default on) with Session::word_count for the status
  bar; review.wordCount takes {"includeTextBoxes"?: bool} and reports it.
- Word Count dialog: "Include text boxes, footnotes and endnotes" checkbox.
- Document::word_count stays body-only (docx Words property, NUMWORDS, stats).
- Tests: doc (used vs orphaned stories, nesting, self-nested box), engine
  (default on, toggle sticks, deleting a box drops its words).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* canvas: select, drag to move and resize pictures, shapes and text boxes

Click a picture or shape (or a text box's border) to select it: a frame with
eight handles. Drag it to move it, drag a handle to resize it (pictures keep
their proportions from a corner, Shift toggles), arrows nudge a floating one
(Ctrl/Alt: 1 pt), Escape cancels a drag. While a text box's text is edited its
frame shows dashed with handles. Typing or Enter with a text box selected
goes into it.

Reusable pieces, no per-kind code:
- layout: Placed::Object, one hit area for every picture/shape/text box
  (replaces the text-box-only item); DocLayout::object_at / object /
  text_box / find_object (page-hinted, no whole-document scans per frame).
- engine: arrange.bounds {width,height,x,y,page} (floating objects keep their
  anchors on the same page; inline ones float where dropped; another page
  re-anchors to the nearest line) and arrange.nudge; objects::min_size shared
  with the UI.
- ui: frame.rs, a document-agnostic transform frame (handles, hit testing,
  drag/resize maths, painting); objects.rs wires it to document objects.
- geom: Rect::expand. control: ui.press / ui.release for half-way drags.

Rendering: nothing is laid out or rasterised while dragging. The preview is
the object's own pixels from the cached page texture (moves, picture
resizes) or a light outline (text box/shape resizes); the drop is one
command, one relayout, one undo step.

Tests: frame maths, layout hit areas, engine move/resize/nudge across
anchors and pages, hostile values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* moving an object anchors it to the paragraph it's dropped on, so text wraps

A floating object only pushes aside text from its anchor paragraph down, and a
move kept the old anchor: drag a text box above its paragraph and the text
there ran straight under it.

- arrange.bounds now anchors the object to the paragraph under its top edge
  (the last one starting at or above it on that page) and positions it
  relative to that paragraph's column and top, as Word does: the text it
  lands on wraps, and it moves with that text. On a page where no paragraph
  starts it anchors to the nearest line, positioned on the page.
- layout: Placed::Object / ObjectHit carry the paragraph origin that column-
  and paragraph-relative offsets start from.
- engine: edit_obj / edit_float change an object at a position (with_obj
  uses it).
- Test: a box dropped over an earlier paragraph anchors there and the lines
  beside it stop short of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* text boxes hide text that doesn't fit, as in Word

Text past a text box's bottom spilled out over the page below it.

- layout: fit_box keeps what fits in a box: whole lines that fit (a line that
  only partly fits is hidden too) and other items starting inside; the first
  line always stays so even a tiny box shows something. Done in layout, so the
  canvas, PNG and PDF agree and hidden text can't be clicked; no renderer
  changes. Reusable for anything laid out into a fixed height.
- BOX_INSET_X / BOX_INSET_Y name the text box internal margins (were 7.2/3.6
  literals).
- Test: overflow hidden, every shown line inside the box, a tall box shows
  everything, a minimum-size box shows its first line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* square wrap flows text on both sides of a floating object

Text only used the wider side of a floating picture, shape or text box; Word's
Square wrap fills both sides when there's room.

- para: row_spans gives the spans of a row around the objects in its way (each
  at least 0.5" wide, else the line goes below); a row cut in two is two Lines
  sharing top and height, the second marked `beside`. Everything per line
  (alignment and justification per side, hit testing, caret, selection,
  drawing, PDF) works unchanged; a row is as tall as its tallest part.
- layout: a row stays on one page; line numbers count rows, not parts.
- Exclusions that don't overlap a line horizontally no longer narrow it.
- Test: text both sides of a mid-column box (same row, left then right,
  hittable), a too-narrow gap stays empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* drop a deleted or cut text box's text from the document

Deleting or cutting a text box left its story in Document::parts: never shown
or saved to .docx, but listed by document.inspect and kept in memory.

- doc: prune_text_boxes removes the text box stories no box shows (nested
  ones too); free without text boxes, one paragraph pass otherwise. Session
  runs it after every editing command. Undo snapshots and the clipboard hold
  their own copies, so undo and cut/paste are unaffected.
- doc: reachable() walks the stories objects lead to; counted_stories (word
  count) and prune_text_boxes share it, and it walks blocks directly instead of
  building paragraph paths.
- Tests: orphaned and nested-orphan boxes go, shown ones (body, nested, in a
  header) stay; delete then undo, cut then paste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* word count's include-text-boxes setting survives a restart

The Word Count dialog's "Include text boxes, footnotes and endnotes" was a
session field, so it reset to on every launch.

- engine: Prefs, the editing preferences the front end saves between runs
  (count_notes moves there; Session::prefs is the one source of truth).
- ui: SavedPrefs pairs UiState with the engine Prefs; WordApp::saved_prefs /
  restore_prefs. UI fields stay at the top level of ui.json, so existing files
  still load (the new `editing` section defaults).
- wordcraft app: saves and restores SavedPrefs instead of UiState alone.
- Tests: an old prefs file loads with Word's default; the setting round-trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* text boxes and floating objects outside the body; footnotes in text boxes

Text boxes in table cells, headers, footers, footnotes and other text boxes
showed an empty frame (or nothing), and floating pictures and shapes there
weren't drawn. Footnotes inserted inside a text box were never numbered or
placed.

- layout: place_objects, one routine for the pictures, shapes and text boxes
  in a paragraph's lines (floating ones drawn, every one's area, text boxes'
  text), used by body paragraphs and by layout_box (cells, headers, footers,
  notes, box contents). Floats are positioned against an AnchorFrame (column,
  paragraph, margins, page); headers and footers pass where the page is, so
  page/margin-anchored objects land right (a footer is laid out again only
  when it has floating objects); elsewhere they're placed in their container.
  Nested boxes show MAX_TEXT_BOX_DEPTH (doc) levels deep.
- Placed::Object / ObjectHit carry their story; DocLayout::object(&Pos),
  object lookups include headers and footers, header_footer_text_box_at.
  A part's lines are looked up in headers and footers too (caret, typing).
- Footnotes: numbered in reading order with text boxes where they are
  (Document::objects_in_reading_order); a box's footnotes go on the page of
  its line (Document::notes_in_text_box), sharing the body's reservation.
- engine: insert.textBox always puts the caret in the new box; disabled
  inside a text box (as in Word).
- canvas: click into a header/footer's text boxes while editing it; leaving a
  box returns to where it is; body dimming computed once per frame and used
  for both the render and its cache key. objects: only body objects are
  selectable, only body paragraphs' objects movable; frames off the body
  show without handles.
- Tests: boxes in header (inline and page-placed), footer, cell and note show
  text and take the caret; footnotes in a box numbered and placed in order;
  a box in a table cell takes the caret and its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* word count: a selected object counts the whole document, in the dialog too

With a picture, shape or text box selected the status bar showed the
document's total but Review > Word Count counted the selection (0 words).

- engine: cmd::objects::object_selection, "the selection is exactly one
  object"; review.wordCount counts the document then. The UI's
  objects::selected now uses it (one definition).
- Test: an object selection counts the document; a text selection itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* review: bound nested text box expansion; tidy-ups

Blocker: laying out a text box lays out the boxes inside it, which could fan
out unboundedly (a box whose shapes show itself, or chains of boxes each
showing the next many times) - 30 shapes made 1.7M layout items. Footnote
numbering's walk fanned out the same way.

- doc: BoxBudget, one bound for both: a box isn't expanded inside itself,
  nesting stops at MAX_TEXT_BOX_DEPTH, an outermost box expands at most 64
  boxes inside it (so one hostile box can't starve the next page's header
  boxes) and a pass at most 20,000.
- layout: Ctx carries a BoxBudget for the whole layout; place_objects asks it
  before laying out a box's text. DocLayout::text_boxes reports how many were.
- doc: walk_objects (objects_in_reading_order, notes_in_text_box) uses it too.
- Tests (crafted documents): a box showing itself 30 times lays out once; a
  five-level chain fanning out 30 ways stays within the budget, for layout and
  for the walk; a box inside a box still shows its text. Weakening the budget
  makes them fail (20,000 boxes / 600,001 visits).

Tidy-ups:
- para: row spans in a VecDeque (pop_front, not remove(0)).
- fit_box: a text box's text area excludes both the top and bottom margins.
- theme: Tokens::handle / handle_shadow for the frame handles (light, dark);
  the drag preview's opacity is a named constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* box budget: no overall cap; bound the .docx writer too; tests

An audit of the previous commit found:

- The 20,000-box overall cap starved ordinary long documents: one budget per
  layout, spent by body boxes first, then one per page for header boxes (two
  for footers with floats), so a long document's header boxes went blank on
  every page. It wasn't needed: only nesting grows exponentially, and
  MAX_NESTED caps that per outermost box, keeping the work linear. Removed.
- The .docx writer still expanded a shape's story only depth-limited (a box
  showing itself 30 times saved as ~28,000 nested copies, ~7 MB zipped). It
  now uses the same BoxBudget, and only for text box parts.
- Layout expanded a shape's story whatever its part kind (a shape pointing
  at a header showed the header's text); now text box parts only, as the
  walk does. The story's blocks are cloned only once the budget agrees.
- Translations of "Count words in text boxes and notes" said footnotes only;
  now footnotes and endnotes, in each catalog's own terms.
- Tests: no timing assert or leftovers; a long document with 400 boxes and a
  header box shows all of them on every page; notes_in_text_box follows
  nested boxes once; saving a self-showing box stays small (fails at 7 MB
  without the bound).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Arrange: nudging or dragging an aligned float keeps it in place first

main (#136) reads w:align into Float.h_align/v_align, which overrides the offset; the PR's
nudge only changed the offset and drag only reset the anchors. Nudge now converts an
aligned axis into a column/paragraph offset that keeps the object where it is, and a drag
clears the alignment. Test included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fonts: a lookup that races another thread's load of the same family no longer falls back

FontDb::face fell back to Source Sans 3 when its load_cataloged added nothing, which happens
when another thread loaded the family between the miss and the load. word::resolve memoises
that, so the wrong font stuck for the process; it made layout tests on main flaky
(justified_lines_shrink_spaces_in_word_2013_mode, space_shrinking_is_only_for_justified_text
failed 6 of 15 runs on origin/main). Look again after loading whatever it returns. Regression
test races 8 threads on fresh databases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout tests: text box and two-sided wrap tests measure what they mean

square_wrap_flows_text_on_both_sides compared xs.last() (the end of a trailing space, which
may hang past the edge) with the edge; compare the last non-space cluster's right edge.
text_box_hides_text_that_does_not_fit compared a tall box's lines with another document's
line count; compare with its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Weeb Labs <weeblabs@VFIEVOX3.Router>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:51:38 -04:00
c7efead47b Layout: place floating pictures and shapes the way Word does (#136)
Pictures and shapes anchored in a .docx often landed in the wrong place:
the reader folded every margin-relative position into "margin", ignored
<wp:align>, and kept only distL as the distance from text. Floats in
headers, footers and table cells were not drawn at all.

Document model (doc):
- Anchor gains the left/right/top/bottom/inside/outside margin areas,
  the anchor character and the anchor line
- Float gains h_align/v_align (FloatAlign: start, center, end, inside,
  outside), which take precedence over the offsets, and separate
  dist_top/dist_bottom (dist stays the left/right distance)

DOCX (docx): read and write wp:align, every relativeFrom value and
distT/distB/distL/distR.

Layout (layout):
- float_rect positions against each reference area, with alignment
- floats in headers, footers, table cells and text boxes are placed,
  drawn (behind-text ones under the text) and wrapped around; headers
  and footers get a page frame so page-relative floats land in page
  coordinates, also when the header's height is measured for the body
  top
- a paragraph's first line pushed below a top-and-bottom float is drawn
  (and hit-tested) where it was laid out
- floats follow their paragraph to a new column or page, and are placed
  at the paragraph's actual top (space before counted once, contextual
  spacing respected)
- wrap areas use each distance as given; the old 9pt minimum is gone, so
  a float with distance 0 now touches its text as in Word
- a list item's label is counted once (Ctx::next_label) and passed into
  every layout of its paragraph (Ctx::para_labelled), so laying it out
  again around floats doesn't advance the list counters twice; a
  paragraph whose top moved is only laid out again when the page has
  something to wrap around

Engine: switching an inline object to a wrapping style gives it Word's
default 9pt side distance; the position presets clear any alignment.

Approximations: inside/outside margins and alignments ignore mirrored
margins (treated as left/right); character- and line-relative positions
fall back to the column and the paragraph top.

Tests: docx fixture and round trip for alignment, reference areas and
distances; layout tests for alignment, the pushed-down first line,
floats moving to page two (with space before), header and cell floats,
header floats not changing the body top, hostile distances, and list
items counted once when laid out again (contextual spacing, page break
before, beside a float in a table cell).

Co-authored-by: Oliver Zieschang <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 07:07:36 -04:00
Tony Ganesh PrajapatiandTony bcfe9111ca #147: wire Picture Format UI for inserted pictures (#150)
* #147: wire Picture Format UI for inserted pictures

* #147: fix Picture Format state bugs from review (alt/crop buffers, undo coalescing, pending target, tab fallback)

* #147: review round 2 (cancel pending change on next action, alt media check, drag-only undo join)

---------

Co-authored-by: Tony <gittonyp@gmail.com>
2026-10-10 06:34:46 -04:00
Brandon ThomasandClaude Opus 5.5 d95e09ba25 Clippy/fmt clean workspace, ROADMAP with honest parity estimate; xtask ci green
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:11:08 -04:00
Brandon ThomasandClaude Opus 5.5 c29182e8bc M1: DOCX read/write (OOXML) wired into open/save; floating objects; picture & shape tools; mail merge, envelopes, labels; contextual spacing; export without proofing marks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:46:40 -04:00