Use the merged craft-libs #1467 commit for craft-rtf and its codepage dependency, leaving other shared pins unchanged. Flush pending breaks before a second break and at end of input so blank and omitted-image pages retain every boundary. Add semantic readback coverage for those cases.
`as_rtf.rs` maps the pages onto craft-rtf's small model and calls
`craft_rtf::write`, as DesignCraft's RTF export does, instead of building
the header, font and colour tables, runs and table rows itself.
craft-rtf is pinned to storytold/craft-libs#1467, whose writer looks fonts
and colours up in constant time, as PdfCraft's own writer did. Only
craft-rtf moves; the lockfile changes for it and craft-codepage.
The file holds the same text, sizes, bold, italic, colours and table cells.
Each formatted run is now its own group, so the tests that pinned the old
bytes read the RTF back with `craft_rtf::read` instead. They pass against
the old writer too.
A page that starts with a table put its `\page` inside the first cell,
because nothing ended the paragraph before `\trowd`. The break now stands
in a paragraph of its own (`rtf_page_breaks_stay_out_of_tables`, which
fails against the old writer).
* chore: cut over image, png, tiff, and zune-jpeg to craft-image-codecs
* chore: cut over xfa XML parsing from roxmltree to craft-xml
* chore: cut over export DOCX and PPTX packaging to craft-opc and craft-xml
* style: cargo fmt
Review asked for the near misses of joining wrapped lines. A short line,
a numbered item and a symbol-font bullet were already covered; these add
a line beside another column and a poem between paragraphs.
Resolved against main's ruled tables (#5154, #6626) and clipboard export:
the new tests pass main's rules argument to tables(), and the parity
notes keep both sides' wording.
- A line change into a block of another style is a wrap only when the next
line starts within half a line below the last one; a line far above it
(the next column) starts a paragraph. The size compare treats NaN as
"not the same size".
- A smaller block must overlap a block's interior, not just touch its edge,
to stop the block being used as a hint.
- font_family drops only the part after the last hyphen, and only when it
names a style (Segoe-UI-Bold gives Segoe UI).
- text_extract's description and html_truncated doc now cover a page's own
copy caps and say that rect HTML is in line order.
- Parity: the new tests are evidence; column selection HTML is listed as
missing (plain text only).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
- Spaces HTML would collapse keep their width across runs, at a
paragraph's start and after a break.
- font_family returns the families to try: spaced, then as spelt
('Sim Sun','SimSun'); a hyphen splits only before a style word, so
MS-Gothic stays whole.
- clip_html reports its cut; empty paragraphs count against the cap;
the span cap is checked before a paragraph opens; ClipBuilder's
Default starts a paragraph; a negative size clamps to 1pt.
- RichCopy::truncated: a capped copy is marked, the clipboard then puts
plain text only, and text_extract sets html_truncated. No HTML for any
page after the shared budget runs out.
- A style change at a line end is a wrap, not a paragraph; the block
hint is skipped when a smaller block overlaps it; no editor reports
styles_incomplete.
- A column selection copies plain text only.
Each new test fails with its fix reverted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
pa-Arab-PK is Punjabi in Arabic script, which Word keeps as right-to-left
text, but word_lang looked only at the language and wrote w:val. A script
subtag (Arab, Hebr, Syrc, Thaa; Hans, Hant, Hani, Jpan, Kore, Hang, Hira,
Kana; Latn, Cyrl, Grek) now decides over the language. It is read only in
its own place, after the language and any extended languages, so private
use such as en-x-arab names no script.
A singleton (one letter or digit) starts an extension, or with x private
use, and BCP 47 needs a subtag after it. en-x and en-a were written as the
document's language. They and a singleton straight after another are now
left out; one-character subtags inside private use stay allowed.
BCP 47 sets no maximum length; 35 characters is only the least a protocol
must hold. A well formed tag such as en-US-u-ca-gregory-nu-latn-co-phonebk
was left out of both exports. The bound is now 255, kept only so a PDF's
/Lang can't put an unbounded string in the file.
Word export named no language, so Word checked the spelling of every
export as English and underlined a whole document in any other
language. The PDF already says its language in the catalog's /Lang.
When that is a well formed language tag, Word gets it as the default
language of the text (w:docDefaults), in the attribute Word keeps for
its script: w:eastAsia for Chinese, Japanese and Korean, w:bidi for
Arabic, Hebrew, Persian and Urdu, w:val for the rest. Both HTML
exports, with and without CSS, get <html lang>. Without /Lang, or with a
malformed one, the files are exactly as before.
docx_with_language, html_with_language and html_simple_with_language
take the language; docx, html and html_simple stay as they are and call
them with none. The engine reads /Lang through the existing initial
view.
The new export and engine tests fail without the change.
Review fixes for Copy with formatting.
- ui-egui: the HTML copy goes through one arboard Clipboard, created on
first use and kept in the app (os_clipboard::with_kept, under the same
process-wide lock), instead of a fresh one per copy. On X11 the copied
data is served only while its Clipboard lives, so a fresh one dropped
at once could leave nothing to paste without a clipboard manager. A
failed create or set falls back to egui's plain-text copy.
- ui-egui: html_clipboard_override stands in for the system clipboard in
tests. With the system clipboard on, Copy sets HTML and plain text in
one set and emits no CopyText; when the set is refused it emits
CopyText with the plain text. The HTML is built only when a clipboard
will take it (system clipboard on, not the web build). The
last_copied_html field is gone.
- export: clipboard HTML text is escaped in one pass and also drops C1
controls, a bare carriage return and the bidirectional overrides and
isolates. Font sizes are written within 1 to 1000 pt.
- engine: the glyph-to-block step budget is a parameter, tested; the copy
says styles_incomplete only when matching actually ran out of steps.
- automation: text_extract format "html" has one 32 MiB HTML budget
across all pages; a page past it gets none, and the result carries
html_truncated. The tool description says so.
- parity: the new tests are evidence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Copying selected page text now also puts an HTML fragment on the system
clipboard, so a paste into a word processor or mail client keeps
paragraphs, bold, italic, size, colour and font family. The plain text
is unchanged and stays on the clipboard as the alternative.
- export: clip.rs, the clipboard model (ClipText of paragraphs of
styled spans), its builder with caps (20 000 spans, 2 MiB of text,
16 MiB of HTML), font_family (subset tag and style suffixes off,
camel case spaced) and clip_html. Family names are reduced to
letters, digits, space, '-', '_' and '.', so a font name cannot leave
the CSS string; text goes through esc, which drops control
characters.
- engine: Document::rich_copy(page, text, glyphs). Glyphs (view space)
take the style of the reading block their centre lies in; blocks are
style-homogeneous. A line wrap inside a block is a space (none after
a hyphen), a change of block a new paragraph. Refused when the
document does not allow copying. compare.rs's display unit and
weight/slant inference are shared, not duplicated.
- automation: text_extract takes format "text" (default) or "html";
html adds each page's html and styles_incomplete, for the whole page
or only what rect takes. Other values are InvalidArgs.
- ui-egui: Copy of a run or column selection writes HTML plus the
plain text in one arboard set (desktop only); the web build and
Select All of the whole document stay plain text. last_copied_html
records what was produced, for tests.
- parity: view.copy-with-layout is partial; notes list what is missing
(RTF, underline/strike-through, tables, Select All, web, glyphs
outside reading blocks).
Formatting inference and the HTML serialiser are adapted from pdfcer
(MIT, same author) at 540b00ef1a405d1baa67c92f3858535037c7cde8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
* feat(export): add simple HTML mode
* test(automation): reject simple HTML mode for other formats
* test(export): check simple HTML without new parser dev-dependencies
The parser test pulled html5ever, markup5ever_rcdom and seven more crates
into Cargo.lock for one test; plain string checks cover the same markup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Chinese and Japanese text wraps between characters, so the word space
put at each joined line break showed up inside the text. No space is
added now when both sides of the break are Chinese or Japanese
characters, their punctuation or fullwidth forms; Korean, which spaces
its words, still gets one. A line ending in a full-width full stop,
exclamation or question mark (also inside closing corner brackets) now
ends its paragraph like one ending in a full stop.
The new test lines fail without the change. The wrapped sentence test
now checks the Markdown export too, which goes through the same
joining since it was added.
Word and LibreOffice often draw a list's bullet in a symbol font, a
tab stop before the item's text, so each bullet is a block of its own.
Table detection read the bullets and the items as two columns, and
every export wrote the list as a borderless two-column table.
Each bullet is now paired with the text that starts within three times
its size after it on its line. The text runs on from its bullet, and
bullets stay out of table detection, so Word gets one paragraph per
item with the bullet first. The Symbol and Wingdings bullets Word
writes (U+F0B7, U+F0A7) count. A dash or a middle dot counts only when
nothing starts left of it on its line, so a dash that stands for
"none" in a table row stays a cell.
The new export and automation tests fail without the change.
Reading splits a line wherever its style changes, and export joined
those blocks along one line only (#4158). A sentence with a bold name
in one line and an italic name in the next came out of Word, HTML, RTF
and PowerPoint as one paragraph per line.
A paragraph whose last line reaches the right edge of the page's text
now runs on into the line just below it, when that line starts at the
same left edge in the same size. Headings, list items, a line that
ends a sentence, an indented line and a line after paragraph space
keep their break. A slanted run whose box leans into the next word by
up to 0.3 of its size still runs on into it.
The new export and automation tests fail without the change.
The reading blocks keep style boundaries, so "Before Chevron after." with an
italic case name reached the Office exporters as three blocks, and each writer
emitted one paragraph per block. A block that continues another on the same
line (the test `continuations` already applies to keep such blocks out of
table cells) is now a run in its own size, bold/italic and colour inside the
paragraph of the block it continues, in Word, HTML, RTF and PowerPoint.
- `continued_from` names the block each one continues (nearest end);
`continuations` is its `is_some`, so table detection is unchanged.
- `paragraphs` groups blocks left to right, joining only a block that starts
strictly further left, with the same rotation, and never a table cell or the
slide title.
- Block text is trimmed, so the engine records the source's spaces at its ends
(`Block::space_around`, from the reading block's editor text); runs keep that
space, or one where the page shows a gap.
- A paragraph is a heading only if its runs are (a bold lead-in no longer makes
its line a heading); a smaller footnote marker does not demote a heading.
Style changes that wrap onto the next line still split paragraphs; that needs
the reading-block grouping change and is left for later.
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Conflicts: ROADMAP.md, docs/roadmap.md: both sides' status, progress and change-log lines; M10 row has both sentences. parity/acrobat-features.toml: export.docx evidence has both tests, notes has both statements.
Conflicts: ROADMAP.md and docs/roadmap.md took both sides' status lines, progress-log and change-log rows; M10 table row from #4311, M11 row from #4310.
* chore: resolve SonarCloud maintainability issues
* fix(packaging): use verify_sha256 helper function
* style: cargo fmt
* fix: restore wildcard imports and fix method-reference type errors
Revert the glob-import expansions (they missed SomIndices and left unused
imports), revert char::is_ascii_digit where the iterator yields char by
value, and pass the content slice to prune_doomed_xobjects as &[Vec<u8>].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep XObject decodability check without binding content in interp
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
Conflict in crates/content/src/lib.rs: main ends inline images where the
renderer's tokenizer ends them and counts the images whose end is a guess
(Parsed::ambiguous_images); this branch reads operators through the
streaming Ops iterator that parse collects. The iterator's BI branch takes
main's inline_image result and keeps the ambiguous-image count, which parse
copies into Parsed and Ops::ambiguous_images reports.
The body-size tally and the RTF colour table searched every slot seen so
far for each block, and the RTF writer searched the colour table again for
every paragraph and cell, so a document with many distinct sizes or colours
made export quadratic. Both keep their first-seen order and look slots up
in a hash map.
Office export encoded and kept a separate copy of an image for every place
a page drew it, so a short content stream repeating Do on one large image
multiplied it into gigabytes before the writers' limits applied. Each image
object is now encoded once and its bytes shared (pdfcraft_export::Image
holds an Arc<[u8]>), distinct pictures share a 512 MiB budget whose
overflow lists the page as truncated, and RTF, which leaves pictures out,
prepares none.
A page coloured with Edit a PDF > Background exported to Word as a
white page: export pages carried text and images only, and the Word
section has no page colour.
- pdfcraft-edit `page_background`: the colour of the first thing a
page paints, when that is an opaque fill (no soft mask, Normal blend)
in a plain gray, RGB or CMYK colour, of a path with a rectangle
covering the visible page, not clipped smaller; `None` otherwise.
- `export::Page::background` carries it; the Word writer sets it as
the document background (`w:background`, plus the
`w:displayBackgroundShape` setting Word needs to show it) when every
page shares one non-white colour. Word has one background per
document, so differing colours are left out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Table detection took a sentence whose link was set in another colour
and font, split into blocks that run on into each other, for two table
columns, and took a paragraph's lines, whose box overlaps the line
before, for a cell beside it. The table it built put the paragraph's
first line in one cell, a fragment of the link in another, and dropped
the paragraph's later lines. A block that starts where another on the
same line ends now adds no column of its own, and a row whose cells
overlap is not a table row; a superscript beside a number stays in its
table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
A VML rectangle with an image did not show the picture in Word. Pictures on
positioned pages are now wp:anchor drawings behind the text, sharing the
picture XML with inline pictures. At most 1000 pictures / 512 MiB are embedded;
empty images are skipped.
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>