mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 00:31:10 +00:00
ODT: pictures keep the size their file gives (gap 19) (#5721)
* ODT gap 19: pictures keep the size their file gives Opening a file fitted a picture wider than the text to the text, and shrank any picture wider than 6.5 in to that, even with its size stated. So a save with no edit to it took its paragraph off being written as read, and LibreOffice laid it out differently. Pictures in OpenDocument files keep the size they state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ODT: a picture keeps a size stated on one side, in proportion (gap 19) Only a frame stating both sides kept them. On one side alone a picture past a column's width was still fitted to it, and past 1,584 pt each side was cut separately, which distorted it. A size past any page can no longer overflow the sizing either. Found by CodeRabbit on this PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ODT: an inline picture with no stated size is fitted to the text (gap 19) Skipping the fit for OpenDocument files left one wider than the text on a small page, which `main` fits. A size the file states still stands, and a test now fails without that. The fit happens when it is read, so its paragraph stays as read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ODT: an object's picture is fitted too, and one side follows the picture's own shape (gap 19) An inline object with no stated size kept its picture's width on a small page, which main fitted. A picture stated on one side took the other from the size already capped to the page: 10 by 1000 pixels at 500 pt wide came out 468 by 1584 pt, not 15.84 by 1584. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ODT: a frame stating a size of nothing is fitted like one stating none (gap 19) svg:width="0pt" counted as a stated size for fitting, though the picture then came at its own size, so it could run past the text on a small page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ODT: a picture in line that is raised, turned or mirrored is fitted too (gap 19) Such a picture has a float record that is still in line, which the reader's fit skipped, so with no size stated it stayed 468 pt wide on a small page. main fitted it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gap 19: docs status lines are main's, with this change in front Merging main had kept this branch's older status lines in ROADMAP.md and docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped main's newer entries and the parity doc's CLI path note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Order the parity header's change notes newest first --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
7 files changed
+246
-7
No files matched your search
+3
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (progress log: ODT pictures keep the size their file gives); previously trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts wLine truncated
|
||||
>
|
||||
> **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged.
|
||||
>
|
||||
@@ -150,6 +150,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-11-05: ODT pictures keep the size their file gives (gap 19): opening a file shrank ones wider than the text, so a save with no edit to them changed how LibreOffice laid them out.
|
||||
- 2026-11-05: ODT frames keep the page they are anchored to (gap 19): written without its number, a frame on page 3 came back on page 1 in LibreOffice after a save.
|
||||
- 2026-11-05: ODT documents keep their language (gap 19). It was lost on save after numbering or a new style, and LibreOffice then showed the document in its own default language; a document in another language was also proofed as English.
|
||||
- 2026-11-05: ODT table and row backgrounds are kept (gap 19): they were dropped, and gone in LibreOffice after a save.
|
||||
@@ -928,6 +929,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` is preserved with true, false and absent values; OOXML properties follow schema order. Grid layout remains open. |
|
||||
| 2026-11-04 | trivial | Progress log: RTF Unicode fallback counts; readiness estimates unchanged |
|
||||
| 2026-10-28 | trivial | Per-page ink object lookups, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change |
|
||||
| 2026-11-05 | trivial | Progress log: ODT pictures keep the size their file gives (gap 19) |
|
||||
| 2026-11-05 | trivial | Progress log: ODT frames keep the page they are anchored to (gap 19) |
|
||||
| 2026-11-05 | trivial | Progress log: ODT table and row backgrounds (gap 19) |
|
||||
| 2026-11-05 | trivial | Progress log: ODT heading text of another size (gap 19) |
|
||||
|
||||
@@ -10,10 +10,15 @@ pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Opti
|
||||
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
|
||||
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
|
||||
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
|
||||
// HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`).
|
||||
// HTML, Markdown, RTF…: pictures fit the text width (see `io::fit_pictures`). An
|
||||
// OpenDocument file says how big they are, and one wider than the text is meant to be;
|
||||
// changing it would take its paragraph off being written as read. Its reader fits those
|
||||
// that state no size.
|
||||
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
|
||||
r.map(|mut doc| {
|
||||
crate::io::fit_pictures(&mut doc);
|
||||
if !matches!(other, "odt" | "ott" | "fodt") {
|
||||
crate::io::fit_pictures(&mut doc);
|
||||
}
|
||||
draw_charts(&mut doc);
|
||||
doc
|
||||
})
|
||||
|
||||
@@ -12724,6 +12724,44 @@ fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() {
|
||||
assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}");
|
||||
}
|
||||
|
||||
/// A picture an OpenDocument file makes wider than the text (a rule across the whole page) stays
|
||||
/// that wide, and its paragraph is written as read: opening a file changes nothing in it.
|
||||
#[test]
|
||||
fn an_odts_picture_wider_than_the_text_keeps_its_size() {
|
||||
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-left="3cm" fo:margin-right="3cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame draw:name="Rule" text:anchor-type="paragraph" svg:x="-1cm" svg:width="18cm" svg:height="0.2cm"><draw:image xlink:href="Pictures/r.png"/></draw:frame>Text</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat();
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in [
|
||||
("mimetype", b"application/vnd.oasis.opendocument.text".as_slice()),
|
||||
("content.xml", content.as_bytes()),
|
||||
("styles.xml", styles.as_bytes()),
|
||||
("Pictures/r.png", &png),
|
||||
] {
|
||||
z.start_file(name, o).unwrap();
|
||||
std::io::Write::write_all(&mut z, data).unwrap();
|
||||
}
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
|
||||
let width = doc.body[0]
|
||||
.as_para()
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, .. } = o { Some(*w) } else { None });
|
||||
let want = 18.0 * 72.0 / 2.54;
|
||||
assert!(width.is_some_and(|w| (w - want).abs() < 0.1), "{width:?}, not fitted to the text");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap();
|
||||
let mut saved = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut saved).unwrap();
|
||||
assert!(saved.contains(r#"draw:name="Rule""#) && saved.contains(r#"svg:width="18cm""#), "written as read: {saved}");
|
||||
}
|
||||
|
||||
/// Numbering or a new style makes the styles be written from the model on save: a document in
|
||||
/// German stays in German, rather than taking the default language of what opens it next.
|
||||
#[test]
|
||||
@@ -12997,6 +13035,47 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
|
||||
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
||||
}
|
||||
|
||||
/// A picture an OpenDocument file gives no size is shown at its own, fitted to the text as other
|
||||
/// formats' are; one with a stated size keeps it, in line too.
|
||||
#[test]
|
||||
fn an_odts_picture_is_fitted_only_when_it_states_no_size() {
|
||||
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image::RgbImage::new(1000, 500).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
let png = png.into_inner();
|
||||
let open = |attrs: &str| {
|
||||
let content = format!(
|
||||
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in [
|
||||
("mimetype", b"application/vnd.oasis.opendocument.text".as_slice()),
|
||||
("content.xml", content.as_bytes()),
|
||||
("styles.xml", styles.as_bytes()),
|
||||
("Pictures/a.png", &png),
|
||||
] {
|
||||
z.start_file(name, o).unwrap();
|
||||
std::io::Write::write_all(&mut z, data).unwrap();
|
||||
}
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
|
||||
doc.body[0]
|
||||
.as_para()
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, h, .. } = o { Some((*w, *h)) } else { None })
|
||||
.unwrap()
|
||||
};
|
||||
let (w, h) = open("");
|
||||
assert!((w - 306.14).abs() < 0.5 && (h - 153.07).abs() < 0.5, "fitted to the text: {w} {h}");
|
||||
let (w, _) = open(r#" svg:width="18cm" svg:height="9cm""#);
|
||||
assert!((w - 18.0 * 72.0 / 2.54).abs() < 0.5, "as the file says: {w}");
|
||||
}
|
||||
|
||||
/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a
|
||||
/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is
|
||||
/// tracked as an insertion by `rev`, or as a deletion when `delete`.
|
||||
|
||||
@@ -111,6 +111,13 @@ impl Body<'_> {
|
||||
self.frame = None;
|
||||
return;
|
||||
}
|
||||
// In line with no stated size, a picture comes at its own: fitted to the text, as
|
||||
// other formats' pictures are. A stated size is the file's.
|
||||
let room = self.sec.text_width();
|
||||
let says = |v: Option<f32>| v.is_some_and(|v| v.is_finite() && v > 0.0);
|
||||
// (Still in line when raised, turned or mirrored.)
|
||||
let in_line = float.as_ref().is_none_or(|f| f.wrap == wordcraft_doc::para::Wrap::Inline);
|
||||
let fit = (!says(w) && !says(h) && in_line && room.is_finite() && room > 0.0).then_some(room);
|
||||
let inline = match (boxed, eq) {
|
||||
(Some(blocks), _) => {
|
||||
let mut look = std::mem::take(&mut self.frame_look);
|
||||
@@ -149,12 +156,14 @@ impl Body<'_> {
|
||||
.and_then(|data| picture(data, w, h, alt.trim()))
|
||||
.or_else(|| crate::odt::objects::placeholder(&o.mime).and_then(|p| make_img(p, w, h, alt.trim())));
|
||||
shown.map(|mut img| {
|
||||
fit_to(&mut img, fit);
|
||||
img.float = float.map(Box::new);
|
||||
img.object = Some(std::sync::Arc::new(o));
|
||||
Inline::Image(img)
|
||||
})
|
||||
}
|
||||
(None, None) => img.and_then(|data| picture(data, w, h, alt.trim())).map(|mut img| {
|
||||
fit_to(&mut img, fit);
|
||||
img.float = float.map(Box::new);
|
||||
if let Some(clip) = self.frame_clip.take() {
|
||||
img.crop = crop_fractions(clip, &img.data);
|
||||
@@ -399,14 +408,50 @@ pub(in crate::odt) fn readable_picture(data: &[u8]) -> bool {
|
||||
/// A picture from a package's bytes: a raster image, or a Windows metafile (WMF, EMF: LibreOffice
|
||||
/// keeps clip art and logos so), sized `w` × `h` (points) when the frame says.
|
||||
pub(in crate::odt) fn picture(data: Vec<u8>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<model::Img> {
|
||||
if wordcraft_metafile::is_metafile(&data) {
|
||||
// What the frame states, if it could be a size (far past any page, kept from overflowing).
|
||||
let stated = |v: Option<f32>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(1e6));
|
||||
let (w, h) = (stated(w), stated(h));
|
||||
let mut img = if wordcraft_metafile::is_metafile(&data) {
|
||||
// An EMF starts with its header record, type 1.
|
||||
let ext = if data.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
|
||||
// Its own size, when the frame gives none: a placeable WMF's or an EMF's frame, points.
|
||||
let natural = if w.is_some() && h.is_some() { None } else { metafile_size(&data) };
|
||||
return Some(model::sized_img(std::sync::Arc::new(data), ext, natural, w, h, alt));
|
||||
model::sized_img(std::sync::Arc::new(data), ext, natural, w, h, alt)
|
||||
} else {
|
||||
make_img(data, w, h, alt)?
|
||||
};
|
||||
// A size the frame states is the file's, however large (a picture across a whole page): only
|
||||
// a picture with none is shrunk to a column. Stated on one side, the other follows the
|
||||
// picture's proportions.
|
||||
let (w, h) = match (w, h) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
(Some(w), None) => (w, w * height_per_width(&img.data)),
|
||||
(None, Some(h)) => (h / height_per_width(&img.data), h),
|
||||
(None, None) => return Some(img),
|
||||
};
|
||||
// Past what a document holds, it shrinks in proportion.
|
||||
let (w, h) = match w.max(h) {
|
||||
most if most <= 1584.0 => (w, h),
|
||||
_ if w >= h => (1584.0, h * 1584.0 / w),
|
||||
_ => (w * 1584.0 / h, 1584.0),
|
||||
};
|
||||
(img.w, img.h) = (w.max(1.0), h.max(1.0));
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// `img` shrunk in proportion to `room` points wide, when it's given and the picture is wider.
|
||||
fn fit_to(img: &mut model::Img, room: Option<f32>) {
|
||||
if let Some(room) = room.filter(|room| img.w > *room) {
|
||||
img.h = (img.h * room / img.w).max(1.0);
|
||||
img.w = room.max(1.0);
|
||||
}
|
||||
make_img(data, w, h, alt)
|
||||
}
|
||||
|
||||
/// A picture's height for each point of its width, by its own size, before anything caps it (1
|
||||
/// when it doesn't say).
|
||||
fn height_per_width(data: &[u8]) -> f32 {
|
||||
let own = if wordcraft_metafile::is_metafile(data) { metafile_size(data) } else { model::image_px(data).map(|(w, h)| (w as f32, h as f32)) };
|
||||
own.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0).map_or(1.0, |(w, h)| h / w)
|
||||
}
|
||||
|
||||
/// A metafile's own size, points, from its header: a placeable WMF's bounding box in its units
|
||||
|
||||
@@ -205,6 +205,81 @@ fn object_without_mathml_falls_back_to_its_replacement_image() {
|
||||
assert!(imgs[0].data.as_slice() == png.as_slice());
|
||||
}
|
||||
|
||||
/// A picture of `w` by `h` pixels, as a PNG.
|
||||
fn png_of(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
image::RgbImage::new(w, h).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
png.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_stated_on_one_side_keeps_its_own_proportions() {
|
||||
// A hundred times as tall as wide: only the cap on a page's size may shrink it, in proportion.
|
||||
let frame = r#"<draw:frame text:anchor-type="as-char" svg:width="500pt"><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png_of(10, 1000))]);
|
||||
let flow = parse(&pkg).unwrap();
|
||||
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||
assert!((img.w - 15.84).abs() < 0.01 && (img.h - 1584.0).abs() < 0.01, "{} {}", img.w, img.h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_objects_picture_with_no_stated_size_is_fitted_to_the_text() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let frame =
|
||||
r#"<draw:frame text:anchor-type="as-char"><draw:object xlink:href="./Object 1"/><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let pkg = zip_of(&[
|
||||
("content.xml", content.as_bytes()),
|
||||
("styles.xml", styles.as_bytes()),
|
||||
("Object 1/content.xml", b"<office:spreadsheet/>"),
|
||||
("Pictures/a.png", &png_of(1000, 500)),
|
||||
]);
|
||||
let flow = parse(&pkg).unwrap();
|
||||
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||
assert!(img.object.is_some(), "still the object");
|
||||
assert!((img.w - 306.14).abs() < 0.5 && (img.h - 153.07).abs() < 0.5, "{} {}", img.w, img.h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_stating_a_size_of_nothing_is_fitted_to_the_text() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let frame = r#"<draw:frame text:anchor-type="as-char" svg:width="0pt"><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/a.png", &png_of(1000, 500))]);
|
||||
let flow = parse(&pkg).unwrap();
|
||||
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||
assert!((img.w - 306.14).abs() < 0.5 && (img.h - 153.07).abs() < 0.5, "{} {}", img.w, img.h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_in_line_raised_or_turned_with_no_size_is_fitted_to_the_text() {
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let auto = r#"<office:automatic-styles><style:style style:name="fr1" style:family="graphic"><style:graphic-properties style:vertical-pos="middle" style:vertical-rel="char"/></style:style></office:automatic-styles>"#;
|
||||
for attrs in [r#" draw:style-name="fr1""#, r#" draw:transform="rotate (0.5) translate (0cm 0cm)""#] {
|
||||
let frame = format!(r#"<draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}>{auto}<office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/a.png", &png_of(1000, 500))]);
|
||||
let flow = parse(&pkg).unwrap();
|
||||
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||
assert!(img.w <= 306.5, "{attrs}: {} {}", img.w, img.h);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const NS_T: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
||||
|
||||
/// A package with `content.xml` and, when given, `styles.xml`.
|
||||
|
||||
@@ -168,6 +168,38 @@ fn an_object_in_a_header_is_kept_too() {
|
||||
assert!(entry(&saved, "styles.xml").contains("<draw:object xlink:href=\"./Object M"));
|
||||
}
|
||||
|
||||
/// A frame's stated size goes before fitting a column: on either side alone the other follows the
|
||||
/// picture's proportions, and only past what a document holds does it shrink, in proportion.
|
||||
#[test]
|
||||
fn a_picture_keeps_the_size_its_frame_states_on_either_side() {
|
||||
let png = |w: u32, h: u32| {
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::RgbImage::new(w, h).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
let size = |w: Option<f32>, h: Option<f32>| {
|
||||
let img = crate::odt::body::drawing::picture(png(200, 100), w, h, "").unwrap();
|
||||
(img.w, img.h)
|
||||
};
|
||||
let near = |got: (f32, f32), want: (f32, f32)| assert!((got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01, "{got:?}, not {want:?}");
|
||||
// Both, one or the other, past a column's 468 pt.
|
||||
near(size(Some(600.0), Some(100.0)), (600.0, 100.0));
|
||||
near(size(Some(600.0), None), (600.0, 300.0));
|
||||
near(size(None, Some(400.0)), (800.0, 400.0));
|
||||
// Past what a document holds: in proportion, whichever side is stated.
|
||||
near(size(Some(2000.0), Some(1000.0)), (1584.0, 792.0));
|
||||
near(size(Some(2000.0), None), (1584.0, 792.0));
|
||||
near(size(None, Some(1000.0)), (1584.0, 792.0));
|
||||
near(size(Some(f32::MAX), None), (1584.0, 792.0));
|
||||
// None stated, or nothing a size could be: its own, fitted to a column.
|
||||
near(size(None, None), (150.0, 75.0));
|
||||
for junk in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -5.0] {
|
||||
near(size(Some(junk), Some(junk)), (150.0, 75.0));
|
||||
}
|
||||
let wide = crate::odt::body::drawing::picture(png(1000, 500), None, None, "").unwrap();
|
||||
near((wide.w, wide.h), (468.0, 234.0));
|
||||
}
|
||||
|
||||
/// A replacement picture may come before its object. It is kept whatever it is, though WordCraft
|
||||
/// can't show it, and a save from the model writes it back.
|
||||
#[test]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
> **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves.
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-08 · **Change:** trivial (ODT frames keep the page they are anchored to); previously trivial (ODT row: how tab stops are saved, corrected); previously trivial (RTF footnotes, endnotes and comments, #1053); previously trivial (XML line ends in text normalised, #5897); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previouLine truncated
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-08 · **Change:** trivial (ODT pictures keep the size their file gives); previously trivial (ODT frames keep the page they are anchored to); previously trivial (ODT: a document's language kept when its styles are written from the model); previously trivial (ODT table and row backgrounds); previously trivial (ODT row: how tab stops are saved, corrected); previously trivial (RTF footnotes, endnotes and comments, #1053); previously trivial (XML line ends in text normalised, #5897); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizLine truncated
|
||||
> **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes.
|
||||
|
||||
> **Legacy form update:** 2026-10-22 · trivial: field names and selected drop-down labels decode numeric XML character references once.
|
||||
@@ -335,6 +335,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | trivial | ODT row corrected: a file whose tab stops are measured from the indent is saved measuring from it, not always from the margin (the 2026-11-03 change); the default stops are counted from the indent |
|
||||
| 2026-11-05 | trivial | ODT: a picture keeps the size its frame states. Opening a file fitted one wider than the text to it, and any wider than 6.5 in to that, which took its paragraph off being written as read and moved things in LibreOffice after a save Stated on one side, the other follows its proportions. In line with no size stated, it is fitted to the text when read, an object's picture too. A frame stating a width of nothing states no size. A picture in line that is raised, turned or mirrored is fitted too. |
|
||||
| 2026-11-05 | trivial | ODT: a frame or shape anchored to a page keeps its page number (`text:anchor-page-number`). It was written without one, so a frame on page 3 came back on page 1 in LibreOffice after a save. WordCraft's own layout still places it on its anchor paragraph's page. Groups keep theirs too, and a number on another anchor is ignored, as LibreOffice does. Moved in WordCraft onto a spot where a paragraph starts, a frame is placed by that paragraph and its page number is removed, not only hidden. Dropped where no paragraph starts, it stays anchored to the page and its number becomes the new page's. |
|
||||
| 2026-11-05 | trivial | ODT: a document's language, stated in its default style or Standard, is read and written back from the model. After numbering or a new style it was dropped on save, so LibreOffice showed the document in its own default language; WordCraft also proofed such a document as English. A document stating no language has none A language's script (`fo:script`) is kept with it, and a style with no parent takes the default style's language when Standard's differs. The language written is Normal's, as WordCraft has it, not the defaults' under it. |
|
||||
| 2026-11-05 | trivial | ODT: a table's or row's background (`fo:background-color`) shows behind cells with none of their own. It was dropped, on screen and in LibreOffice after a save A cell with no style takes its column's default cell style, and `transparent` clears a parent style's background. A cell style saying `transparent` stops its parent's colour, as in LibreOffice. |
|
||||
|
||||
Reference in new issue
Block a user