mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
Tables: Word 2013 table edge, and floating tables (w:tblpPr) (#137)
* Layout: place floating pictures and shapes the way Word does 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: Claude Opus 5.5 <noreply@anthropic.com> * Tables: place a table's edge by the document's Word compatibility mode Word 2013 and later (compatibilityMode 15) put a left-aligned table's border at the margin plus its indent, moved in by half the first cell's left border; earlier modes line the first cell's text up with the margin instead, the border one cell margin further out. We always did the latter, so in Word 2013+ documents (43% of docxide-pdf's Word reference corpus) every table and its text sat a cell margin (usually 5.4pt) too far left. Settings::compat_mode keeps the document's mode: read from w:compat/w:compatSetting, 12 when a document has no settings or doesn't state one (as Word treats it), 15 for new documents, and written back instead of a fixed 15. The legacy shift now uses the first cell's own left margin. Measured against docxide-pdf's corpus (365 documents, same open fonts): mean SSIM 40.4% -> 42.1%, documents passing SSIM 40 -> 50, passing ink overlap 142 -> 154; 71 documents better, 6 worse (tables nested in a cell whose text Word puts at the outer cell's edge, which docxide-pdf doesn't match either). Tests: a mode-15 table's text sits a cell margin and half a border inside the margin, a mode-12 table's at it; the mode round-trips through .docx and defaults to 12 without settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Layout: floating tables (w:tblpPr) stand at their position, text wraps Floating tables were read as ordinary tables: laid out in the text flow with no regard to their position, and the text after them waited below instead of running beside them. 71 of the 382 documents in docxide-pdf's Word reference corpus have them, typically forms anchored to the text and the margin. TableProps::float (TableFloat) keeps w:tblpPr and w:tblOverlap: what x and y are measured from (column, margin or page; text, margin or page), offsets or alignments, distances from text and whether it may overlap other floating tables. The .docx reader and writer carry it. A floating table at the top level of the body is placed at its position (tblpY below the text where it stands, by default) and takes no room in the flow; its area plus its distances from text joins the page's wrap areas, so the following text runs beside it or below it. One that may not overlap moves below the floating tables in its way, keeping (in Word 2013+ layout) its distance from the text's left edge; in earlier modes an offset places the first cell's text, as an indent does. A table in the flow starts below the floating objects in its way. Floating tables inside table cells and headers are still laid out in their flow. Measured against docxide-pdf's corpus (365 documents, same open fonts), on top of the compatibility-mode commit: mean SSIM 42.1% -> 43.9%, mean line-break agreement 63.5% -> 66.6%, documents passing SSIM 50 -> 55, page count right 303 -> 305; 33 documents better, 4 worse by at most 3.1 points. Tests: a full-width and a narrow floating table that may not overlap stack, the narrow one keeping 9pt from the text edge, and the next paragraph runs beside it; floating table positions round-trip through .docx. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Layout: a floating table taller than a page runs across pages A floating table was always placed whole at its position, so one taller than the page's body ran past the bottom margin and its rows were lost: a Bulgarian programme whose whole body is one floating table came out as 3 pages instead of Word's 6. Such a table now flows across pages like a table in the text (rows splitting and header rows repeating as usual), from the floating table's own left edge; the position is worked out by float_table_x, which the whole-table placement shares. In docxide-pdf's Word reference corpus that document has Word's page count again with all its rows; nothing else changes. Test: an 80-row floating table runs onto a second page, every row drawn above the bottom margin from the table's left edge (was one page). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tidy floating-table code: one alignment helper, named steps, shared mappings No change in behaviour; makes the floating-table code easier to read. - FloatAlign::from_ooxml/ooxml (added with floating pictures) replace the copies of the same mapping in the w:tblpPr reader and writer, and TableFloat::dist_from_text the copies of its clamping. - place_floating_table and float_table_x place by offset or alignment with align_in, like floating pictures do, instead of their own copies. - The overlap rule uses Rect::inset/intersects and names its condition (keeps_text_distance) instead of a one-line chain of comparisons. - below_floats names the step that starts a table in the flow below the floating objects in its way. - The floating-table functions move next to place_table, which calls them, from after `mod tests;` at the end of the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
Oliver Zieschang
Brandon Thomas
parent
c7efead47b
commit
6e73fe40e3
@@ -253,8 +253,14 @@ pub struct Settings {
|
||||
pub footnote_format: section::NumFormat,
|
||||
pub endnote_format: section::NumFormat,
|
||||
pub protection: Option<String>,
|
||||
/// Word compatibility mode the document is laid out in (`compatibilityMode`): 15 for Word
|
||||
/// 2013 and later, which places a table's border at the margin rather than its text.
|
||||
pub compat_mode: u32,
|
||||
}
|
||||
|
||||
/// Word's compatibility mode for documents that don't state one.
|
||||
pub const LEGACY_COMPAT_MODE: u32 = 12;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Watermark {
|
||||
@@ -304,6 +310,7 @@ impl Default for Settings {
|
||||
footnote_format: section::NumFormat::Decimal,
|
||||
endnote_format: section::NumFormat::LowerRoman,
|
||||
protection: None,
|
||||
compat_mode: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,6 +622,36 @@ pub struct TableProps {
|
||||
pub shading: Option<Rgb>,
|
||||
/// Alternative text.
|
||||
pub caption: Option<String>,
|
||||
/// Floating placement (`w:tblpPr`); `None` for a table in the text flow.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub float: Option<TableFloat>,
|
||||
}
|
||||
|
||||
/// Where a floating table sits; the text after it wraps around it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableFloat {
|
||||
/// What `x` is measured from: the column (`Column`), the margin or the page.
|
||||
pub h_rel: crate::para::Anchor,
|
||||
/// What `y` is measured from: the text where the table stands (`Paragraph`), the margin or
|
||||
/// the page.
|
||||
pub v_rel: crate::para::Anchor,
|
||||
/// Offsets, points (used when the matching alignment is `None`).
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub h_align: Option<crate::para::FloatAlign>,
|
||||
pub v_align: Option<crate::para::FloatAlign>,
|
||||
/// Distance from surrounding text: left, top, right, bottom (points).
|
||||
pub dist: [f32; 4],
|
||||
/// Whether it may overlap other floating tables (`w:tblOverlap`); Word's default is yes.
|
||||
pub overlap: bool,
|
||||
}
|
||||
|
||||
impl TableFloat {
|
||||
/// The distances from text, finite and clamped (left, top, right, bottom).
|
||||
pub fn dist_from_text(&self) -> [f32; 4] {
|
||||
self.dist.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -88,6 +88,8 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
if let Some(p) = part(rt::NUMBERING) {
|
||||
lenient("numbering", r.read_numbering(&p));
|
||||
}
|
||||
// Without a stated mode (or settings at all) Word lays a document out as Word 2007 did.
|
||||
r.doc.settings.compat_mode = wordcraft_doc::LEGACY_COMPAT_MODE;
|
||||
if let Some(p) = part(rt::SETTINGS) {
|
||||
lenient("settings", r.read_settings(&p));
|
||||
}
|
||||
@@ -547,6 +549,12 @@ impl Reader<'_> {
|
||||
let s = &mut self.doc.settings;
|
||||
for k in root.els() {
|
||||
match k.name.as_str() {
|
||||
"w:compat" => {
|
||||
let mode = k.children("w:compatSetting").find(|c| c.attr("w:name") == Some("compatibilityMode")).and_then(|c| c.attr("w:val"));
|
||||
if let Some(m) = mode.and_then(u32_of) {
|
||||
s.compat_mode = m.clamp(11, 99);
|
||||
}
|
||||
}
|
||||
"w:trackRevisions" => s.track_changes = on_off(k),
|
||||
"w:defaultTabStop" => {
|
||||
if let Some(v) = tw(k, "w:val").filter(|v| *v > 0.0) {
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::collections::HashMap;
|
||||
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign,
|
||||
TabLeader, TabStop, TableLook, TableProps, TextColor, Underline, VAlign, VMerge, VertAlign,
|
||||
TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart};
|
||||
|
||||
@@ -277,6 +277,12 @@ impl PropCtx {
|
||||
"w:tblLayout" => t.fixed = k.attr("w:type") == Some("fixed"),
|
||||
"w:tblCellMar" => t.cell_margins = Some(margins(k)),
|
||||
"w:tblLook" => t.look = look(k),
|
||||
"w:tblpPr" => t.float = Some(table_float(k)),
|
||||
"w:tblOverlap" => {
|
||||
if let Some(f) = t.float.as_mut() {
|
||||
f.overlap = k.attr("w:val") != Some("never");
|
||||
}
|
||||
}
|
||||
"w:tblCaption" => t.caption = k.attr("w:val").map(str::to_string),
|
||||
"w:tblDescription" if t.caption.is_none() => t.caption = k.attr("w:val").map(str::to_string),
|
||||
_ => {}
|
||||
@@ -482,3 +488,26 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
}
|
||||
(s, refs)
|
||||
}
|
||||
|
||||
/// `w:tblpPr`: a floating table's anchors, offsets or alignments and distances from text.
|
||||
fn table_float(e: &El) -> TableFloat {
|
||||
use wordcraft_doc::para::{Anchor, FloatAlign};
|
||||
let pt = |n: &str| e.attr(n).and_then(|v| measure(v, 20.0)).map(|v| v.clamp(-31_680.0, 31_680.0)).unwrap_or(0.0);
|
||||
let dist = |n: &str| pt(n).clamp(0.0, 1584.0);
|
||||
let rel = |n: &str, text: Anchor| match e.attr(n) {
|
||||
Some("page") => Anchor::Page,
|
||||
Some("margin") => Anchor::Margin,
|
||||
_ => text,
|
||||
};
|
||||
let align = |n: &str| e.attr(n).and_then(FloatAlign::from_ooxml);
|
||||
TableFloat {
|
||||
h_rel: rel("w:horzAnchor", Anchor::Column),
|
||||
v_rel: rel("w:vertAnchor", Anchor::Paragraph),
|
||||
x: pt("w:tblpX"),
|
||||
y: pt("w:tblpY"),
|
||||
h_align: align("w:tblpXSpec"),
|
||||
v_align: align("w:tblpYSpec"),
|
||||
dist: [dist("w:leftFromText"), dist("w:topFromText"), dist("w:rightFromText"), dist("w:bottomFromText")],
|
||||
overlap: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,7 +756,8 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec<u8> {
|
||||
w.close(tag);
|
||||
}
|
||||
w.open("w:compat", &[]);
|
||||
w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", "15")]);
|
||||
let mode = doc.settings.compat_mode.clamp(11, 15).to_string();
|
||||
w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", &mode)]);
|
||||
w.close("w:compat");
|
||||
w.close("w:settings");
|
||||
w.into_bytes()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, ParaProps, RowProps, TabAlign, TabLeader,
|
||||
TableLook, TableProps, TextColor, VAlign, VMerge, VertAlign,
|
||||
TableFloat, TableLook, TableProps, TextColor, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
|
||||
use crate::units::{n, twips};
|
||||
@@ -305,6 +305,9 @@ pub fn tblpr(w: &mut W, t: &TableProps) {
|
||||
if let Some(s) = &t.style {
|
||||
w.val("w:tblStyle", s);
|
||||
}
|
||||
if let Some(f) = &t.float {
|
||||
table_float(w, f);
|
||||
}
|
||||
if let Some(p) = t.width_pct {
|
||||
w.empty("w:tblW", &[("w:w", &n(round(p.clamp(0.0, 1000.0) * 50.0))), ("w:type", "pct")]);
|
||||
} else if let Some(v) = t.width {
|
||||
@@ -397,3 +400,36 @@ pub fn tcpr(w: &mut W, c: &CellProps) {
|
||||
}
|
||||
w.close("w:tcPr");
|
||||
}
|
||||
|
||||
/// `w:tblpPr` and `w:tblOverlap` for a floating table.
|
||||
fn table_float(w: &mut W, f: &TableFloat) {
|
||||
use wordcraft_doc::para::Anchor;
|
||||
let rel = |a: Anchor, text: &'static str| match a {
|
||||
Anchor::Page => "page",
|
||||
Anchor::Margin => "margin",
|
||||
_ => text,
|
||||
};
|
||||
let [left, top, right, bottom] = f.dist_from_text().map(twips);
|
||||
let fin = |v: f32| twips(if v.is_finite() { v.clamp(-31_680.0, 31_680.0) } else { 0.0 });
|
||||
let (x, y) = (fin(f.x), fin(f.y));
|
||||
let mut a: Vec<(&str, &str)> = vec![
|
||||
("w:leftFromText", &left),
|
||||
("w:rightFromText", &right),
|
||||
("w:topFromText", &top),
|
||||
("w:bottomFromText", &bottom),
|
||||
("w:vertAnchor", rel(f.v_rel, "text")),
|
||||
("w:horzAnchor", rel(f.h_rel, "text")),
|
||||
];
|
||||
match f.h_align {
|
||||
Some(h) => a.push(("w:tblpXSpec", h.ooxml(true))),
|
||||
None => a.push(("w:tblpX", &x)),
|
||||
}
|
||||
match f.v_align {
|
||||
Some(v) => a.push(("w:tblpYSpec", v.ooxml(false))),
|
||||
None => a.push(("w:tblpY", &y)),
|
||||
}
|
||||
w.empty("w:tblpPr", &a);
|
||||
if !f.overlap {
|
||||
w.val("w:tblOverlap", "never");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +473,11 @@ fn anchor_alignment_reference_areas_and_distances() {
|
||||
assert_eq!((b.dist, b.dist_top, b.dist_bottom), (0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() {
|
||||
assert_eq!(read_body("<w:p/>").settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_rfonts_without_ascii_inherits_doc_defaults_font() {
|
||||
// Issue #93: Normal names only East Asian / complex-script fonts, so Latin text keeps the
|
||||
|
||||
@@ -889,6 +889,44 @@ fn ensure_empty_and_table_end_document_is_valid() {
|
||||
assert_eq!(r.body.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_mode_round_trips() {
|
||||
let mut d = Document::new();
|
||||
assert_eq!(d.settings.compat_mode, 15, "new documents are Word 2013+ documents");
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(back.settings.compat_mode, 15);
|
||||
d.settings.compat_mode = 14;
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(back.settings.compat_mode, 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_tables_round_trip() {
|
||||
use wordcraft_doc::para::{Anchor, FloatAlign};
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
let mut d = Document::new();
|
||||
let mut t = Table::new(1, 2, 200.0);
|
||||
let f = TableFloat {
|
||||
h_rel: Anchor::Margin,
|
||||
v_rel: Anchor::Paragraph,
|
||||
x: 0.0,
|
||||
y: -1.65,
|
||||
h_align: None,
|
||||
v_align: None,
|
||||
dist: [9.0, 0.0, 12.0, 3.0],
|
||||
overlap: false,
|
||||
};
|
||||
t.props.float = Some(f);
|
||||
let mut page = Table::new(1, 1, 100.0);
|
||||
let centred =
|
||||
TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Margin, h_align: Some(FloatAlign::Center), y: 36.0, overlap: true, ..Default::default() };
|
||||
page.props.float = Some(centred);
|
||||
d.body = vec![Arc::new(Block::Table(t)), Arc::new(Block::Table(page)), para_block(Paragraph::with_text("after", CharProps::default()))];
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let floats: Vec<_> = back.body.iter().filter_map(|b| if let Block::Table(t) = &**b { t.props.float } else { None }).collect();
|
||||
assert_eq!(floats, [f, centred]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_level_overrides_round_trip() {
|
||||
use wordcraft_doc::numbering::{Counters, Level};
|
||||
|
||||
+100
-2
@@ -20,7 +20,7 @@ use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::numbering::{Counters, Level};
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, InlineObject, Wrap};
|
||||
use wordcraft_doc::props::{Border, CharProps, Rgb};
|
||||
use wordcraft_doc::props::{Border, CharProps, Rgb, TableFloat};
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, StoryRef};
|
||||
use wordcraft_geom::Rect;
|
||||
@@ -463,6 +463,8 @@ struct PageBuilder<'a> {
|
||||
prev: Option<(String, bool, f32)>,
|
||||
/// Wrap areas of floating objects on this page (see [`wrap_area`]).
|
||||
excl: Vec<(Rect, bool)>,
|
||||
/// Wrap areas of the floating tables on this page, which others may not overlap.
|
||||
float_tables: Vec<Rect>,
|
||||
/// Line numbering counter.
|
||||
line_no: u32,
|
||||
/// Index of the first body item of the current page (vertical alignment shifts from here).
|
||||
@@ -525,6 +527,7 @@ impl PageBuilder<'_> {
|
||||
self.apply_valign();
|
||||
self.flush_notes();
|
||||
self.excl.clear();
|
||||
self.float_tables.clear();
|
||||
if self.sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Page) {
|
||||
self.line_no = 0;
|
||||
}
|
||||
@@ -658,6 +661,7 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) ->
|
||||
orig_bottom: 0.0,
|
||||
prev: None,
|
||||
excl: Vec::new(),
|
||||
float_tables: Vec::new(),
|
||||
line_no: 0,
|
||||
page_items_start: 0,
|
||||
};
|
||||
@@ -1156,7 +1160,22 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 {
|
||||
fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) {
|
||||
pb.prev = None;
|
||||
let width = pb.col_w();
|
||||
let tl = table::layout_table(ctx, StoryRef::Body, t, &[block as u32], width, 1);
|
||||
let mut tl = table::layout_table(ctx, StoryRef::Body, t, &[block as u32], width, 1);
|
||||
if let Some(f) = t.props.float.filter(|_| !pb.web) {
|
||||
// Before Word 2013 layout (compatibility mode 15) an offset places the first cell's text,
|
||||
// so the edge sits a cell margin further out.
|
||||
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(t) } else { 0.0 };
|
||||
let h: f32 = tl.rows.iter().map(|r| r.height).sum();
|
||||
if h <= pb.bottom - pb.top + 0.01 {
|
||||
place_floating_table(pb, &tl, &f, legacy, block, body_top);
|
||||
return;
|
||||
}
|
||||
// Too tall for a page: Word lets it run across pages like a table in the text, from its
|
||||
// own left edge.
|
||||
tl.x = float_table_x(pb, tl.width, &f, legacy) - pb.col_x();
|
||||
}
|
||||
// A table in the text flow starts below the floating objects in its way.
|
||||
below_floats(pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width);
|
||||
let header_rows: Vec<usize> = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect();
|
||||
let place = |pb: &mut PageBuilder, row: &table::RowLayout| {
|
||||
let (x, y) = (pb.col_x() + tl.x, pb.y);
|
||||
@@ -1201,6 +1220,85 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
|
||||
}
|
||||
}
|
||||
|
||||
/// Place floating table `tl` (`w:tblpPr`): at its own position, which takes no room in the text
|
||||
/// flow; the text after it wraps around it.
|
||||
fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &TableFloat, legacy: f32, block: usize, body_top: f32) {
|
||||
let h: f32 = tl.rows.iter().map(|r| r.height).sum::<f32>().clamp(0.0, 100_000.0);
|
||||
let w = tl.width.clamp(1.0, 100_000.0);
|
||||
let [dl, dt, dr, db] = f.dist_from_text();
|
||||
for attempt in 0..2 {
|
||||
let s = pb.sect;
|
||||
let h_area = float_table_h_area(pb, f);
|
||||
// The text a table anchored to it stands in: where the next paragraph starts.
|
||||
let v_area = match f.v_rel {
|
||||
Anchor::Page => (0.0, s.page_h),
|
||||
Anchor::Margin => (s.margin_top, s.text_height()),
|
||||
_ => (pb.y, 0.0),
|
||||
};
|
||||
let x = float_table_x(pb, w, f, legacy);
|
||||
let mut r = Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h);
|
||||
// Word moves a table that may not overlap below the floating tables in its way; in Word
|
||||
// 2013+ layout one placed by an offset keeps its distance from the text's left edge.
|
||||
let keeps_text_distance = legacy == 0.0 && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column);
|
||||
for _ in 0..64 {
|
||||
let area = r.inset(-dl, -dt, -dr, -db);
|
||||
let in_the_way = pb.float_tables.iter().filter(|o| !f.overlap && o.intersects(&area));
|
||||
let Some(below) = in_the_way.map(|o| o.bottom()).reduce(f32::max) else { break };
|
||||
r.y = below + dt;
|
||||
if keeps_text_distance {
|
||||
r.x = r.x.max(h_area.0 + dl);
|
||||
}
|
||||
}
|
||||
if attempt == 0 && r.bottom() > pb.bottom + 0.01 && !pb.at_top() && f.v_rel == Anchor::Paragraph {
|
||||
pb.advance(block, body_top);
|
||||
continue;
|
||||
}
|
||||
if let Some(pg) = pb.page() {
|
||||
let mut y = r.y;
|
||||
for row in &tl.rows {
|
||||
for it in &row.items {
|
||||
let mut it = it.clone();
|
||||
it.translate(r.x, y);
|
||||
pg.items.push(it);
|
||||
}
|
||||
y += row.height;
|
||||
}
|
||||
}
|
||||
let area = r.inset(-dl, -dt, -dr, -db);
|
||||
pb.excl.push((area, false));
|
||||
pb.float_tables.push(area);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// The area a floating table's `x` is measured in: its start and width on the page.
|
||||
fn float_table_h_area(pb: &PageBuilder, f: &TableFloat) -> (f32, f32) {
|
||||
let s = pb.sect;
|
||||
match f.h_rel {
|
||||
Anchor::Page => (0.0, s.page_w),
|
||||
Anchor::Margin => (s.margin_left + s.gutter, s.text_width()),
|
||||
_ => (pb.col_x(), pb.col_w()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A floating table's left edge on the page (`legacy`: the first cell's left margin before Word
|
||||
/// 2013 layout, when an offset places the cell's text).
|
||||
fn float_table_x(pb: &PageBuilder, w: f32, f: &TableFloat, legacy: f32) -> f32 {
|
||||
let x = align_in(float_table_h_area(pb, f), f.x, w, f.h_align);
|
||||
if f.h_align.is_none() { x - legacy } else { x }
|
||||
}
|
||||
|
||||
/// Move `pb.y` below the floating objects in the way of something spanning `x0..x1` that
|
||||
/// starts there (a table in the text flow doesn't wrap around them).
|
||||
fn below_floats(pb: &mut PageBuilder, x0: f32, x1: f32) {
|
||||
for _ in 0..64 {
|
||||
let y = pb.y;
|
||||
let in_the_way = pb.excl.iter().filter(|(r, _)| r.y <= y + 0.01 && r.bottom() > y && r.x < x1 && r.right() > x0);
|
||||
let Some(below) = in_the_way.map(|(r, _)| r.bottom()).reduce(f32::max) else { break };
|
||||
pb.y = below;
|
||||
}
|
||||
}
|
||||
|
||||
fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &SectionProps)]) {
|
||||
let total = pages.len() as u32;
|
||||
// Pages per section.
|
||||
|
||||
@@ -18,13 +18,18 @@ pub struct RowLayout {
|
||||
pub struct TableLayout {
|
||||
/// Table x offset within the column.
|
||||
pub x: f32,
|
||||
#[allow(dead_code)]
|
||||
pub width: f32,
|
||||
pub rows: Vec<RowLayout>,
|
||||
}
|
||||
|
||||
const DEFAULT_MARGINS: [f32; 4] = [0.0, 5.4, 0.0, 5.4];
|
||||
|
||||
/// The first cell's left margin: how far its text sits inside the table's edge.
|
||||
pub(crate) fn first_cell_left_margin(t: &Table) -> f32 {
|
||||
let def = t.props.cell_margins.unwrap_or(DEFAULT_MARGINS);
|
||||
t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1]
|
||||
}
|
||||
|
||||
fn style_parts(ctx: &Ctx, t: &Table) -> Option<TableStyleParts> {
|
||||
let id = t.props.style.as_deref()?;
|
||||
ctx.doc.styles.chain(id).iter().rev().find_map(|s| s.table.clone())
|
||||
@@ -62,18 +67,27 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
acc += w;
|
||||
}
|
||||
colx.push(acc);
|
||||
// Word places the table so cell text lines up with the margin: shift left by the left cell margin.
|
||||
// Word 2013 and later (compatibility mode 15) put the table's border at the margin (plus its
|
||||
// indent); earlier modes line the first cell's text up with it instead.
|
||||
let margins_def = t.props.cell_margins.unwrap_or(DEFAULT_MARGINS);
|
||||
let indent = t.props.indent.unwrap_or(0.0);
|
||||
let x = match t.props.align {
|
||||
Some(Align::Center) => (avail - total) / 2.0,
|
||||
Some(Align::Right) => avail - total,
|
||||
_ => indent - margins_def[1],
|
||||
};
|
||||
// The style's borders, overlaid by the table's own side by side.
|
||||
let mut tborders = parts.as_ref().and_then(|p| p.borders).unwrap_or_default();
|
||||
if let Some(own) = t.props.borders {
|
||||
tborders.overlay(&own);
|
||||
}
|
||||
let first_cell = t.rows.first().and_then(|r| r.cells.first());
|
||||
let x = match t.props.align {
|
||||
Some(Align::Center) => (avail - total) / 2.0,
|
||||
Some(Align::Right) => avail - total,
|
||||
_ if ctx.doc.settings.compat_mode >= 15 => {
|
||||
// The border is centred on the edge, so half of it sits outside: Word moves the
|
||||
// table in by that half.
|
||||
let border = first_cell.and_then(|c| c.props.borders.and_then(|b| b.left)).or(tborders.left);
|
||||
indent + border.filter(Border::is_visible).map_or(0.0, |b| b.width.clamp(0.0, 12.0) / 2.0)
|
||||
}
|
||||
_ => indent - first_cell_left_margin(t),
|
||||
};
|
||||
let nrows = t.rows.len();
|
||||
let header_rows = t.props.look.header_row;
|
||||
// First pass: lay out every cell's content.
|
||||
|
||||
@@ -710,6 +710,90 @@ fn list_items_beside_a_float_in_a_cell_are_counted_once() {
|
||||
assert_eq!(labels(&lay(&d)), ["1.", "2.", "3."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_mode_decides_where_a_table_s_edge_sits() {
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders};
|
||||
let cell_text_x = |mode: u32| {
|
||||
let mut d = Document::from_text("");
|
||||
d.settings.compat_mode = mode;
|
||||
let line = Some(Border { style: BorderStyle::Single, width: 0.5, color: None, space: 0.0 });
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
t.props.borders = Some(Borders { top: line, left: line, bottom: line, right: line, between: line, inside_v: line });
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("cell", Default::default()))];
|
||||
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t))];
|
||||
let l = lay(&d);
|
||||
l.pages[0].items.iter().find_map(|i| if let Placed::Lines { x, .. } = i { Some(*x) } else { None }).unwrap()
|
||||
};
|
||||
// Word 2013+: the border at the margin (moved in by half its width), the text a cell margin
|
||||
// inside it. Earlier modes: the text at the margin, the border a cell margin outside it.
|
||||
assert!((cell_text_x(15) - (72.0 + 0.25 + 5.4)).abs() < 0.01, "{}", cell_text_x(15));
|
||||
assert!((cell_text_x(12) - 72.0).abs() < 0.01, "{}", cell_text_x(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_tables_take_no_room_and_text_wraps_beside_them() {
|
||||
use wordcraft_doc::para::Anchor;
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
let mut d = Document::from_text("Body text beside the narrow table.");
|
||||
let cell = |t: &mut Table, text: &str| {
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))]
|
||||
};
|
||||
let mut wide = Table::new(1, 1, 468.0);
|
||||
cell(&mut wide, "Wide");
|
||||
wide.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, overlap: false, ..Default::default() });
|
||||
let mut narrow = Table::new(1, 1, 200.0);
|
||||
cell(&mut narrow, "Narrow");
|
||||
narrow.props.float =
|
||||
Some(TableFloat { h_rel: Anchor::Column, v_rel: Anchor::Paragraph, dist: [9.0, 0.0, 9.0, 0.0], overlap: false, ..Default::default() });
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(wide)).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(narrow)).unwrap();
|
||||
let l = lay(&d);
|
||||
let lines: Vec<(f32, f32, f32)> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| if let Placed::Lines { x, y, para, .. } = i { Some((*x, *y, para.lines[0].left)) } else { None })
|
||||
.collect();
|
||||
let [(wx, wy, _), (nx, ny, _), (bx, by, bleft)] = lines.as_slice() else { panic!("{lines:?}") };
|
||||
// The wide table stands where the text is; the narrow one may not overlap it, so it goes
|
||||
// right below, keeping its 9pt from the text's left edge.
|
||||
assert!(*wy >= 72.0 && *wy < 80.0, "wide at {wy}");
|
||||
assert!(*ny > *wy + 10.0, "narrow at {ny}, below the wide table at {wy}");
|
||||
assert!((nx - wx - 9.0).abs() < 0.01, "narrow text at {nx}, wide text at {wx}");
|
||||
// The paragraph doesn't wait below them: it runs beside the narrow table, level with it.
|
||||
assert!((by - ny).abs() < 2.0, "body at {by}, narrow table text at {ny}");
|
||||
assert!(bx + bleft > nx + 200.0, "body text at {}, right of the narrow table", bx + bleft);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_floating_table_taller_than_a_page_runs_across_pages() {
|
||||
use wordcraft_doc::para::Anchor;
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
let mut d = Document::from_text("After the table.");
|
||||
let mut t = Table::new(80, 1, 300.0);
|
||||
for (i, r) in t.rows.iter_mut().enumerate() {
|
||||
r.cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Row {i}"), Default::default()))];
|
||||
}
|
||||
t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: -30.0, overlap: false, ..Default::default() });
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 2, "{} page(s)", l.pages.len());
|
||||
// Every row is drawn on a page, none past a page's bottom margin, and all from the table's
|
||||
// own left edge.
|
||||
let mut rows = 0;
|
||||
for p in &l.pages {
|
||||
for it in &p.items {
|
||||
if let Placed::Lines { x, y, story: StoryRef::Body, path, .. } = it
|
||||
&& path.0.len() > 1
|
||||
{
|
||||
rows += 1;
|
||||
assert!(*y < 792.0 - 72.0, "row drawn at {y}, below the bottom margin");
|
||||
assert!(*x < 72.0, "row text at {x}, not from the table's edge 30pt left of the margin");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(rows, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_line_break_right_after_a_slash() {
|
||||
// Word keeps "and/or" and web addresses whole on a line when they fit.
|
||||
|
||||
Reference in New Issue
Block a user