mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 00:40:33 -06:00
Integration: track t02
Docs: status lines combined (t02's entries first); log rows unioned; file-format-parity .rtf row from t02 (#1653, duplicate .rtf row dropped as t02 did), .odt row word-merged.
This commit is contained in:
+13
-3
File diff suppressed because one or more lines are too long
@@ -1,7 +1,8 @@
|
||||
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
|
||||
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
|
||||
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), and Mac OS Roman
|
||||
//! (10000, RTF `\mac`).
|
||||
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), Mac OS Roman
|
||||
//! (10000, RTF `\mac`), and the East Asian multibyte code pages (932 Shift-JIS, 936 GBK, 949 UHC,
|
||||
//! 950 Big5), whose bytes decode as runs rather than one at a time.
|
||||
|
||||
/// Windows-1252 code points for 0x80..=0x9F.
|
||||
const CP1252: [u16; 32] = [
|
||||
@@ -286,13 +287,47 @@ pub fn code_page_byte(code_page: u16, b: u8) -> char {
|
||||
if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) }
|
||||
}
|
||||
|
||||
/// The East Asian multibyte code pages a document format names with `\ansicpg` or a font's `\cpg`:
|
||||
/// 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC (Korean) and 950 Big5
|
||||
/// (Traditional Chinese).
|
||||
pub const MULTIBYTE_CODE_PAGES: [u16; 4] = [932, 936, 949, 950];
|
||||
|
||||
/// Whether `code_page` is one of the East Asian multibyte code pages ([`MULTIBYTE_CODE_PAGES`]):
|
||||
/// its bytes decode as a run, a lead byte together with its trail byte.
|
||||
pub fn is_multibyte_code_page(code_page: u16) -> bool {
|
||||
multibyte_encoding_name(code_page).is_some()
|
||||
}
|
||||
|
||||
/// The WHATWG encoding name of an East Asian multibyte code page (see [`MULTIBYTE_CODE_PAGES`]),
|
||||
/// for `encoding_rs::Encoding::for_label`; `None` for the single-byte code pages, whose bytes
|
||||
/// decode one at a time, and anything else.
|
||||
pub fn multibyte_encoding_name(code_page: u16) -> Option<&'static str> {
|
||||
Some(match code_page {
|
||||
932 => "shift_jis",
|
||||
936 => "gbk",
|
||||
949 => "euc-kr",
|
||||
950 => "big5",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a document format can name `code_page` for its text: a single-byte Windows, DOS or Mac
|
||||
/// one ([`is_byte_code_page`]) or an East Asian multibyte one ([`is_multibyte_code_page`]).
|
||||
pub fn is_text_code_page(code_page: u16) -> bool {
|
||||
is_byte_code_page(code_page) || is_multibyte_code_page(code_page)
|
||||
}
|
||||
|
||||
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
|
||||
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437… `None` for the ANSI and default charsets (the
|
||||
/// document's code page applies), symbol fonts and the double-byte East Asian charsets.
|
||||
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437, 128 Japanese → 932, 129 Korean → 949,
|
||||
/// 134 Simplified Chinese → 936, 136 Traditional Chinese → 950… `None` for the ANSI and default
|
||||
/// charsets (the document's code page applies) and symbol fonts.
|
||||
pub fn charset_code_page(charset: i32) -> Option<u16> {
|
||||
Some(match charset {
|
||||
77 => MAC_ROMAN_CODE_PAGE,
|
||||
254 | 255 => 437,
|
||||
128 => 932,
|
||||
129 => 949,
|
||||
134 => 936,
|
||||
136 => 950,
|
||||
161 => 1253,
|
||||
162 => 1254,
|
||||
163 => 1258,
|
||||
@@ -302,6 +337,7 @@ pub fn charset_code_page(charset: i32) -> Option<u16> {
|
||||
204 => 1251,
|
||||
222 => 874,
|
||||
238 => 1250,
|
||||
254 | 255 => 437,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -347,6 +383,25 @@ mod tests {
|
||||
assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000));
|
||||
}
|
||||
|
||||
/// #1653: the East Asian charsets and code pages RTF names are multibyte: their bytes decode
|
||||
/// as runs (a lead byte and its trail byte together), not one at a time.
|
||||
#[test]
|
||||
fn east_asian_code_pages_are_multibyte() {
|
||||
assert_eq!(charset_code_page(128), Some(932), "Japanese");
|
||||
assert_eq!(charset_code_page(129), Some(949), "Korean");
|
||||
assert_eq!(charset_code_page(134), Some(936), "Simplified Chinese");
|
||||
assert_eq!(charset_code_page(136), Some(950), "Traditional Chinese");
|
||||
assert_eq!(charset_code_page(255), Some(437), "OEM is single-byte");
|
||||
assert_eq!(MULTIBYTE_CODE_PAGES, [932, 936, 949, 950]);
|
||||
for cp in MULTIBYTE_CODE_PAGES {
|
||||
assert!(is_multibyte_code_page(cp) && is_text_code_page(cp), "{cp}");
|
||||
assert!(!is_byte_code_page(cp) && !SINGLE_BYTE_CODE_PAGES.contains(&cp), "{cp}");
|
||||
assert!(multibyte_encoding_name(cp).is_some(), "{cp}");
|
||||
}
|
||||
assert!(is_text_code_page(1251) && is_text_code_page(437) && is_text_code_page(10000));
|
||||
assert!(!is_text_code_page(0) && !is_text_code_page(9999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oem_code_pages() {
|
||||
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::<String>();
|
||||
|
||||
@@ -73,8 +73,13 @@ pub struct TableStyleParts {
|
||||
pub band_chr: CharProps,
|
||||
/// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`).
|
||||
pub band_borders: Option<Borders>,
|
||||
/// Rows per band (`w:tblStyleRowBandSize`); one when unset.
|
||||
/// Rows per band (`w:tblStyleRowBandSize`). Unset (in the whole based-on chain) or zero, the
|
||||
/// row bands are off: Word reads a missing size as zero, where ECMA-376 says one (MS-OE376
|
||||
/// §2.7.5.7, #3415). Our own banded styles set one.
|
||||
pub band_size: Option<u32>,
|
||||
/// Columns per band (`w:tblStyleColBandSize`); unset or zero, the column bands are off, as
|
||||
/// in Word (MS-OI29500 §17.7.6.5).
|
||||
pub col_band_size: Option<u32>,
|
||||
pub first_col_chr: CharProps,
|
||||
pub total_chr: CharProps,
|
||||
pub total_border_top: Option<Border>,
|
||||
@@ -179,17 +184,24 @@ pub struct PartSet(u16);
|
||||
|
||||
impl PartSet {
|
||||
/// The parts of the cell at row `ri` of `nrows`, grid column `g` spanning `span` of `ncols`,
|
||||
/// in a table with options `look` and row bands of `band_size` rows.
|
||||
pub fn of_cell(look: &TableLook, band_size: u32, (ri, nrows): (usize, usize), (g, span, ncols): (usize, usize, usize)) -> PartSet {
|
||||
/// in a table with options `look`, row bands of `row_size` rows and column bands of
|
||||
/// `col_size` columns (a size of zero turns those bands off, as in Word).
|
||||
pub fn of_cell(
|
||||
look: &TableLook,
|
||||
(row_size, col_size): (u32, u32),
|
||||
(ri, nrows): (usize, usize),
|
||||
(g, span, ncols): (usize, usize, usize),
|
||||
) -> PartSet {
|
||||
let header = look.header_row && ri == 0;
|
||||
let total = look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let first_col = look.first_column && g == 0;
|
||||
let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1;
|
||||
let size = band_size.clamp(1, 1000) as usize;
|
||||
let row_band = look.banded_rows && !header;
|
||||
let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2);
|
||||
let col_band = look.banded_columns && !first_col;
|
||||
let odd_col = g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
|
||||
let rows = row_size.min(1000) as usize;
|
||||
let cols = col_size.min(1000) as usize;
|
||||
let row_band = look.banded_rows && !header && rows > 0;
|
||||
let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / rows.max(1)).is_multiple_of(2);
|
||||
let col_band = look.banded_columns && !first_col && cols > 0;
|
||||
let odd_col = (g.saturating_sub(usize::from(look.first_column)) / cols.max(1)).is_multiple_of(2);
|
||||
let mut bits = TablePart::WholeTable.bit();
|
||||
for (on, p) in [
|
||||
(col_band && odd_col, TablePart::Band1Vert),
|
||||
@@ -270,6 +282,7 @@ impl TableStyleParts {
|
||||
self.header_borders = merge(self.header_borders, patch.header_borders);
|
||||
self.band_borders = merge(self.band_borders, patch.band_borders);
|
||||
self.band_size = patch.band_size.or(self.band_size);
|
||||
self.col_band_size = patch.col_band_size.or(self.col_band_size);
|
||||
self.band_chr.overlay(&patch.band_chr);
|
||||
self.fill = patch.fill.or(self.fill);
|
||||
self.cell_margins = patch.cell_margins.or(self.cell_margins);
|
||||
@@ -846,6 +859,7 @@ fn p_style() -> Style {
|
||||
/// A few table styles of our own (grid, plain, light/medium accents).
|
||||
fn table_styles() -> Vec<Style> {
|
||||
let thin = |c: Rgb| Border { style: BorderStyle::Single, width: 0.5, color: Some(c), space: 0.0 };
|
||||
// Bands of one row and one column, written out so Word bands them too (#3415).
|
||||
let mk = |id: &str, name: &str, parts: TableStyleParts| Style {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
@@ -854,7 +868,7 @@ fn table_styles() -> Vec<Style> {
|
||||
para: ParaProps { space_after: Some(0.0), line_spacing: Some(LineSpacing::Multiple(1.0)), ..ParaProps::default() },
|
||||
priority: Some(60),
|
||||
builtin: true,
|
||||
table: Some(parts),
|
||||
table: Some(TableStyleParts { band_size: Some(1), col_band_size: Some(1), ..parts }),
|
||||
..Default::default()
|
||||
};
|
||||
let accents = [
|
||||
@@ -979,6 +993,25 @@ mod tests {
|
||||
assert!(c.len() <= 2);
|
||||
}
|
||||
|
||||
/// #3415: a band size of zero (Word's reading of a style without one) turns those bands off.
|
||||
#[test]
|
||||
fn band_size_zero_turns_bands_off() {
|
||||
let look = crate::props::TableLook { banded_columns: true, ..Default::default() };
|
||||
let parts = |sizes, ri, g| PartSet::of_cell(&look, sizes, (ri, 5), (g, 1, 4));
|
||||
assert!(parts((1, 1), 1, 1).contains(TablePart::Band1Horz) && parts((1, 1), 1, 1).contains(TablePart::Band1Vert));
|
||||
assert!(parts((1, 1), 2, 2).contains(TablePart::Band2Horz) && parts((1, 1), 2, 2).contains(TablePart::Band2Vert));
|
||||
for (ri, g) in [(1, 1), (2, 2)] {
|
||||
let p = parts((0, 0), ri, g);
|
||||
for band in [TablePart::Band1Horz, TablePart::Band2Horz, TablePart::Band1Vert, TablePart::Band2Vert] {
|
||||
assert!(!p.contains(band), "{band:?} at row {ri}, column {g}");
|
||||
}
|
||||
}
|
||||
// Our banded styles say so, so Word bands them too.
|
||||
let s = StyleSheet::builtin();
|
||||
let t = s.table_style("GridTable4AccentBlue").map(|t| t.parts);
|
||||
assert_eq!(t.map(|t| (t.band_size, t.col_band_size)), Some((Some(1), Some(1))));
|
||||
}
|
||||
|
||||
/// A table style based on another inherits what it doesn't set itself; borders merge edge by
|
||||
/// edge.
|
||||
#[test]
|
||||
|
||||
+26
-3
@@ -43,6 +43,13 @@ pub struct Row {
|
||||
pub controls: ControlWrap,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
/// Grid column where the row's first cell starts: the columns `w:gridBefore` skips.
|
||||
pub fn first_grid(&self) -> usize {
|
||||
(self.props.grid_before as usize).min(MAX_COLS)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Table {
|
||||
#[serde(default)]
|
||||
@@ -86,18 +93,22 @@ impl Table {
|
||||
}
|
||||
|
||||
pub fn cols(&self) -> usize {
|
||||
self.grid.len().max(self.rows.iter().map(|r| r.cells.iter().map(Cell::span).sum::<usize>()).max().unwrap_or(0))
|
||||
let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::<usize>() + r.props.grid_after as usize;
|
||||
self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Grid column where cell `c` of row `r` starts.
|
||||
pub fn grid_col(&self, r: usize, c: usize) -> usize {
|
||||
self.rows.get(r).map(|row| row.cells.iter().take(c).map(Cell::span).sum()).unwrap_or(0)
|
||||
self.rows.get(r).map(|row| row.first_grid() + row.cells.iter().take(c).map(Cell::span).sum::<usize>()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Cell index in row `r` that covers grid column `g`.
|
||||
pub fn cell_at_grid(&self, r: usize, g: usize) -> Option<usize> {
|
||||
let row = self.rows.get(r)?;
|
||||
let mut x = 0;
|
||||
let mut x = row.first_grid();
|
||||
if g < x {
|
||||
return None;
|
||||
}
|
||||
for (i, c) in row.cells.iter().enumerate() {
|
||||
if g < x + c.span() {
|
||||
return Some(i);
|
||||
@@ -495,6 +506,18 @@ impl Table {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grid_before_shifts_the_row() {
|
||||
let mut t = Table::new(2, 3, 300.0);
|
||||
t.rows[1].cells.remove(0);
|
||||
t.rows[1].props.grid_before = 1;
|
||||
assert_eq!(t.grid_col(1, 0), 1);
|
||||
assert_eq!(t.grid_col(1, 1), 2);
|
||||
assert_eq!(t.cell_at_grid(1, 0), None);
|
||||
assert_eq!(t.cell_at_grid(1, 2), Some(1));
|
||||
assert_eq!(t.cols(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_table_shape() {
|
||||
let t = Table::new(3, 4, 468.0);
|
||||
|
||||
@@ -901,7 +901,8 @@ impl Reader<'_> {
|
||||
}
|
||||
t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e));
|
||||
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
|
||||
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000));
|
||||
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).map(|n| n.min(1000));
|
||||
t.col_band_size = tp.child_val("w:tblStyleColBandSize").and_then(u32_of).map(|n| n.min(1000));
|
||||
}
|
||||
for c in s.children("w:tblStylePr").take(64) {
|
||||
let Some(part) = c.attr("w:type").and_then(TablePart::from_ooxml) else { continue };
|
||||
|
||||
@@ -795,10 +795,13 @@ fn style_xml(w: &mut W, st: &Style, flagged: &[StyleKind]) {
|
||||
w.close("w:rPr");
|
||||
}
|
||||
if let Some(t) = &st.table {
|
||||
if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() {
|
||||
if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() || t.col_band_size.is_some() {
|
||||
w.open("w:tblPr", &[]);
|
||||
if let Some(n) = t.band_size {
|
||||
w.val("w:tblStyleRowBandSize", &n.clamp(1, 1000).to_string());
|
||||
w.val("w:tblStyleRowBandSize", &n.min(1000).to_string());
|
||||
}
|
||||
if let Some(n) = t.col_band_size {
|
||||
w.val("w:tblStyleColBandSize", &n.min(1000).to_string());
|
||||
}
|
||||
if let Some(b) = &t.borders {
|
||||
props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]);
|
||||
|
||||
@@ -3652,3 +3652,49 @@ fn shape_gradients_and_transparency_are_read_and_survive_saving() {
|
||||
check(&d);
|
||||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||||
}
|
||||
|
||||
/// #3415: a table style's row and column band sizes (`w:tblStyleRowBandSize`,
|
||||
/// `w:tblStyleColBandSize`, ECMA-376 §17.7.6.5 and §17.7.6.7) are read, zero included, and left
|
||||
/// unset when the file has none, which is how Word reads it: no bands. A style based on one with
|
||||
/// sizes inherits them.
|
||||
#[test]
|
||||
fn table_style_band_sizes_read_as_word_reads_them() {
|
||||
let band = r#"<w:tblStylePr w:type="band1Horz"><w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="C1E4F5"/></w:tcPr></w:tblStylePr>"#;
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}>
|
||||
<w:style w:type="table" w:default="1" w:styleId="TableNormal"><w:name w:val="Normal Table"/></w:style>
|
||||
<w:style w:type="table" w:styleId="Missing"><w:name w:val="Missing"/><w:basedOn w:val="TableNormal"/>{band}</w:style>
|
||||
<w:style w:type="table" w:styleId="One"><w:name w:val="One"/><w:basedOn w:val="TableNormal"/><w:tblPr><w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/></w:tblPr>{band}</w:style>
|
||||
<w:style w:type="table" w:styleId="Zero"><w:name w:val="Zero"/><w:basedOn w:val="One"/><w:tblPr><w:tblStyleRowBandSize w:val="0"/></w:tblPr></w:style>
|
||||
<w:style w:type="table" w:styleId="Child"><w:name w:val="Child"/><w:basedOn w:val="One"/>{band}</w:style>
|
||||
</w:styles>"#
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
let sizes = |id: &str| d.styles.table_style(id).map(|t| (t.parts.band_size, t.parts.col_band_size));
|
||||
assert_eq!(sizes("Missing"), Some((None, None)), "no sizes: no bands, as in Word");
|
||||
assert_eq!(sizes("One"), Some((Some(1), Some(1))));
|
||||
assert_eq!(sizes("Zero"), Some((Some(0), Some(1))));
|
||||
assert_eq!(sizes("Child"), Some((Some(1), Some(1))), "inherited");
|
||||
// Written back as read: still no sizes where there were none.
|
||||
let xml = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/styles.xml");
|
||||
let style = |id: &str| {
|
||||
let start = xml.find(&format!(r#"w:styleId="{id}""#)).unwrap();
|
||||
xml[start..start + xml[start..].find("</w:style>").unwrap()].to_string()
|
||||
};
|
||||
assert!(!style("Missing").contains("BandSize"), "{}", style("Missing"));
|
||||
assert!(style("One").contains(r#"<w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/>"#), "{}", style("One"));
|
||||
assert!(style("Zero").contains(r#"<w:tblStyleRowBandSize w:val="0"/>"#), "{}", style("Zero"));
|
||||
}
|
||||
|
||||
/// #3415: a document saved with one of our banded table styles tells Word its band sizes, so Word
|
||||
/// shades the same rows we do (it reads a style without them as having no bands).
|
||||
#[test]
|
||||
fn our_banded_table_styles_write_their_band_sizes() {
|
||||
let d = Document::new();
|
||||
let xml = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/styles.xml");
|
||||
let start = xml.find(r#"w:styleId="GridTable4AccentBlue""#).expect("Grid Table 4 – Blue is written");
|
||||
let style = &xml[start..start + xml[start..].find("</w:style>").unwrap()];
|
||||
assert!(style.contains(r#"<w:tblPr><w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/>"#), "{style}");
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(back.styles.table_style("GridTable4AccentBlue").map(|t| t.parts.band_size), Some(Some(1)));
|
||||
}
|
||||
|
||||
@@ -166,6 +166,13 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
||||
{
|
||||
return r;
|
||||
}
|
||||
// Delete (and Backspace that doesn't delete them) empties whole cells to one paragraph each,
|
||||
// as in Word (#2802).
|
||||
if matches!(id, "text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward")
|
||||
&& let Some(r) = super::table::clear_cells(s)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
|
||||
let cells = s.cell_range().is_some();
|
||||
match id {
|
||||
|
||||
@@ -615,6 +615,57 @@ pub(crate) fn backspace_cells(s: &mut Session) -> Option<CmdResult> {
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in
|
||||
/// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its
|
||||
/// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text
|
||||
/// is marked deleted as for any selection).
|
||||
pub(crate) fn clear_cells(s: &mut Session) -> Option<CmdResult> {
|
||||
if s.doc.settings.track_changes {
|
||||
return None;
|
||||
}
|
||||
let cr = s.cell_range()?.clone();
|
||||
let t = s.doc.table(cr.story, &cr.table)?;
|
||||
let prefixes: Vec<Vec<u32>> = cr.cells(t).into_iter().map(|(r, c)| cell_prefix(&cr.table, r, c)).collect();
|
||||
let spans = text_spans(s, cr.story, &prefixes);
|
||||
Some(clear_spans(s, cr.table.0.len().saturating_add(2), &spans))
|
||||
}
|
||||
|
||||
/// Empty each span (one cell's text, from its first paragraph to its last; the cell's path is the
|
||||
/// first `depth` steps of its paths) to one paragraph with the last paragraph's formatting, bottom
|
||||
/// up so earlier spans stay valid; the caret goes to the start of the first cell.
|
||||
fn clear_spans(s: &mut Session, depth: usize, spans: &[(Pos, Pos)]) -> CmdResult {
|
||||
// A cell's first block (its first paragraph may have been in a nested table that goes).
|
||||
let first_block = |p: &Pos| {
|
||||
let mut v = p.path.0.get(..depth).unwrap_or_default().to_vec();
|
||||
v.push(0);
|
||||
Pos { story: p.story, path: Path(v), off: 0 }
|
||||
};
|
||||
for (a, b) in spans {
|
||||
super::controls::check_delete(s, a, b)?;
|
||||
}
|
||||
for (a, b) in spans.iter().rev() {
|
||||
let Some(last) = s.doc.para(b.story, &b.path).map(|p| (p.props.clone(), p.mark.clone())) else { continue };
|
||||
s.doc.delete_range(a, b)?;
|
||||
// Now the cell's only block, unless it holds a table nested deeper than the paragraph
|
||||
// walk (`delete_range` leaves those).
|
||||
let first = first_block(a);
|
||||
if s.doc.container(first.story, &first.path).is_some_and(|bl| bl.len() == 1)
|
||||
&& let Ok(p) = s.doc.para_mut(first.story, &first.path)
|
||||
{
|
||||
(p.props, p.mark) = last;
|
||||
p.touch();
|
||||
}
|
||||
}
|
||||
let at = spans.first().map(|(a, _)| s.doc.clamp(&first_block(a))).unwrap_or_else(|| s.sel.focus.clone());
|
||||
s.sel = Selection::caret(at);
|
||||
s.column = None;
|
||||
s.column_mode = false;
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
super::controls::restore_placeholder(s)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn del_table(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
|
||||
@@ -199,6 +199,30 @@ pub fn handle_table(l: &DocLayout, doc: &Document, story: StoryRef, page: usize,
|
||||
.max_by_key(|g| g.table.0.len())
|
||||
}
|
||||
|
||||
/// The move and resize handles of table `table` of `story` on this page, as Word shows them
|
||||
/// while the caret is in the table: the move handle where it starts, the resize handle where it
|
||||
/// ends.
|
||||
pub fn caret_handles(l: &DocLayout, doc: &Document, story: StoryRef, table: &Path, page: usize) -> (Option<Rect>, Option<Rect>) {
|
||||
let rows = doc.table(story, table).map_or(0, |t| t.rows.len());
|
||||
let has_row = |p: usize, r: usize| {
|
||||
l.pages.get(p).is_some_and(|pg| {
|
||||
pg.items.iter().any(|it| matches!(it, Placed::Cell { table: t, story: st, row, .. } if *st == story && t == table && *row == r))
|
||||
})
|
||||
};
|
||||
// A repeated header row, or a row running on to the next page, is drawn on more than one
|
||||
// page: the handles go where the table starts and where it ends (its pages are consecutive).
|
||||
let first = has_row(page, 0) && !page.checked_sub(1).is_some_and(|p| has_row(p, 0));
|
||||
let last = rows.checked_sub(1).is_some_and(|r| has_row(page, r) && !page.checked_add(1).is_some_and(|p| has_row(p, r)));
|
||||
if !first && !last {
|
||||
return (None, None);
|
||||
}
|
||||
// The table's geometry on this page that the pointer handles are drawn and grabbed from.
|
||||
let Some(g) = table_resize::tables_on(l, doc, page).into_iter().find(|g| g.story == story && g.table == *table) else {
|
||||
return (None, None);
|
||||
};
|
||||
(first.then(|| handle(&g)), last.then(|| g.handle()))
|
||||
}
|
||||
|
||||
/// The insert button a target is, if it's one.
|
||||
pub fn insert_mark(t: &Target) -> Option<InsertMark> {
|
||||
let g = &t.geom;
|
||||
@@ -374,7 +398,7 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// before it) or, at the table's right edge, a row's last cell (after it). (row, cell, after)
|
||||
fn beside_line(t: &Table, line: usize) -> Option<(usize, usize, bool)> {
|
||||
t.rows.iter().enumerate().find_map(|(r, row)| {
|
||||
let mut g = 0usize;
|
||||
let mut g = row.first_grid();
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
if g == line {
|
||||
return Some((r, c, false));
|
||||
@@ -425,7 +449,7 @@ fn insert_at(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
/// Where table `table` of `story` is first drawn: its page and top-left corner.
|
||||
fn corner(l: &DocLayout, story: StoryRef, table: &Path) -> Option<(usize, f32, f32)> {
|
||||
pub fn corner(l: &DocLayout, story: StoryRef, table: &Path) -> Option<(usize, f32, f32)> {
|
||||
l.pages.iter().enumerate().find_map(|(pi, pg)| {
|
||||
let cells = pg.items.iter().filter_map(|it| match it {
|
||||
Placed::Cell { rect, table: t, story: st, .. } if *st == story && t == table => Some(*rect),
|
||||
@@ -1027,4 +1051,35 @@ mod tests {
|
||||
let moved = cell_rect(&mut s, 0, 0);
|
||||
assert!(moved.x >= -0.5 && moved.right() <= 612.5 && moved.y >= -0.5, "{moved:?}");
|
||||
}
|
||||
|
||||
/// #2038: a table dragged to the right floats and the empty paragraph after it moves up beside
|
||||
/// it. As in Word, a click anywhere below the table then goes to that paragraph, not to a cell of the last row; a click in a cell still lands there.
|
||||
#[test]
|
||||
fn a_click_below_a_floating_table_goes_to_the_text_not_its_cells() {
|
||||
let mut s = table_session(3, 3);
|
||||
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
|
||||
let (hx, hy) = (h.x + h.w / 2.0, h.y + h.h / 2.0);
|
||||
s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx + 60.0, "toY": hy + 10.0})).unwrap();
|
||||
assert!(first_table(&s).props.float.is_some());
|
||||
let after = Path(vec![table_path(&s).0[0] + 1]);
|
||||
assert!(s.doc.block(StoryRef::Body, &after).is_some_and(|b| b.as_table().is_none()), "a paragraph follows the table");
|
||||
let (first, last) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 2, 2));
|
||||
let (mid, bottom, margin) = ((first.x + last.right()) / 2.0, last.bottom(), s.layout().pages[0].body.x + 5.0);
|
||||
let at = |s: &mut Session, x: f32, y: f32| {
|
||||
s.run("caret.set", &json!({"page": 0, "x": x, "y": y})).unwrap();
|
||||
s.sel.focus.path.clone()
|
||||
};
|
||||
for (name, x, y) in [
|
||||
("just below the middle", mid, bottom + 12.0),
|
||||
("far below the middle", mid, bottom + 200.0),
|
||||
("below, at the margin", margin, bottom + 12.0),
|
||||
] {
|
||||
assert_eq!(at(&mut s, x, y), after, "a click {name}");
|
||||
}
|
||||
let b2 = cell_rect(&mut s, 1, 1);
|
||||
assert_eq!(at(&mut s, b2.x + b2.w / 2.0, mid_y(b2)), cell_pos(&s, 1, 1).path, "in B2");
|
||||
assert_eq!(at(&mut s, last.x + last.w / 2.0, last.bottom() - 1.0), cell_pos(&s, 2, 2).path, "low in C3");
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
assert_eq!(s.sel.focus.path, after, "Ctrl+End");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,14 @@ fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
};
|
||||
let id = s.doc.styles.new_id(&name);
|
||||
let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
|
||||
// Bands of one row and one column unless the style it's based on sets them: Word, like us,
|
||||
// reads a style with no band size as having no bands (#3415).
|
||||
let base = st.based_on.as_deref().and_then(|b| s.doc.styles.table_style(b)).map(|t| t.parts).unwrap_or_default();
|
||||
if base.band_size.is_none() || base.col_band_size.is_none() {
|
||||
let parts = st.table.get_or_insert_with(TableStyleParts::default);
|
||||
parts.band_size = base.band_size.is_none().then_some(1);
|
||||
parts.col_band_size = base.col_band_size.is_none().then_some(1);
|
||||
}
|
||||
edit(&mut st, v)?;
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ impl CellRange {
|
||||
let ((r0, r1), (g0, g1)) = (self.rows, self.cols);
|
||||
let mut out = Vec::new();
|
||||
for (r, row) in t.rows.iter().enumerate().take(r1.saturating_add(1)).skip(r0) {
|
||||
let mut g = 0usize;
|
||||
let mut g = row.first_grid();
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if g <= g1 && end > g0 {
|
||||
|
||||
@@ -1972,6 +1972,67 @@ fn table_text_direction_cycles_and_turns_the_text() {
|
||||
}
|
||||
}
|
||||
|
||||
/// While the caret is in a table, Word shows its move handle past the top-left corner and its
|
||||
/// resize handle past the bottom-right one (#2428); the app paints the ones this gives.
|
||||
#[test]
|
||||
fn the_caret_table_has_its_handles() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
let l = s.layout();
|
||||
let (m, z) = crate::cmd::table_mouse::caret_handles(&l, &s.doc, s.sel.focus.story, &tp, 0);
|
||||
let (m, z) = (m.expect("the move handle"), z.expect("the resize handle"));
|
||||
let (_, x, y) = crate::cmd::table_mouse::corner(&l, s.sel.focus.story, &tp).unwrap();
|
||||
assert!(m.right() <= x && m.bottom() <= y, "past the top-left corner");
|
||||
assert!(z.x > m.right() && z.y > m.bottom(), "past the bottom-right one");
|
||||
// Exactly where the pointer handles are, so a click on them grabs them.
|
||||
let mid = |r: wordcraft_geom::Rect| (r.x + r.w / 2.0, r.y + r.h / 2.0);
|
||||
let (hx, hy) = mid(m);
|
||||
let g = crate::cmd::table_mouse::handle_table(&l, &s.doc, s.sel.focus.story, 0, hx, hy, 4.0).expect("the move handle grabs");
|
||||
assert_eq!(crate::cmd::table_mouse::handle(&g), m);
|
||||
let (hx, hy) = mid(z);
|
||||
let g = crate::cmd::table_resize::table_under(&l, &s.doc, 0, hx, hy, 4.0).expect("the resize handle grabs");
|
||||
assert_eq!(g.handle(), z);
|
||||
let (m, z) = crate::cmd::table_mouse::caret_handles(&l, &s.doc, s.sel.focus.story, &tp, 1);
|
||||
assert!(m.is_none() && z.is_none(), "not on a page without the table");
|
||||
}
|
||||
|
||||
/// A table over more than one page shows its move handle only where it starts and its resize
|
||||
/// handle only where it ends, also when its header row repeats or its one row runs on (#2428).
|
||||
#[test]
|
||||
fn the_caret_table_shows_its_handles_where_it_starts_and_ends() {
|
||||
for header in [true, false] {
|
||||
let mut s = s();
|
||||
if header {
|
||||
// 60 rows under a header row that repeats on the next page.
|
||||
run(&mut s, "insert.table", json!({"rows": 60, "cols": 2}));
|
||||
run(&mut s, "table.properties", json!({"headerRow": true}));
|
||||
} else {
|
||||
// One row whose cell runs on to the next page.
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
for i in 0..80 {
|
||||
run(&mut s, "text.insert", json!({"text": format!("Line {i}")}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
}
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
let story = s.sel.focus.story;
|
||||
let l = s.layout();
|
||||
let holds = |p: usize, r: Option<usize>| {
|
||||
l.pages[p]
|
||||
.items
|
||||
.iter()
|
||||
.any(|it| matches!(it, wordcraft_layout::Placed::Cell { table, row, .. } if *table == tp && r.is_none_or(|r| *row == r)))
|
||||
};
|
||||
assert!(l.pages.len() >= 2 && holds(1, Some(0)), "row 0 is drawn on page 2 too (header {header})");
|
||||
let end = (0..l.pages.len()).rev().find(|p| holds(*p, None)).unwrap();
|
||||
for p in 0..l.pages.len() {
|
||||
let (m, z) = crate::cmd::table_mouse::caret_handles(&l, &s.doc, story, &tp, p);
|
||||
assert_eq!((m.is_some(), z.is_some()), (p == 0, p == end), "page {p} (header {header})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-to-left tables and sections (#362): `table.rtl` and `layout.sectionRtl` set or toggle
|
||||
/// the direction, one Undo step each, and the layout mirrors the table.
|
||||
#[test]
|
||||
@@ -4346,6 +4407,7 @@ fn custom_table_style_applies_modifies_and_saves() {
|
||||
assert_eq!((st.kind, st.name.as_str(), st.based_on.as_deref()), (StyleKind::Table, "Thesis Table", Some("TableGrid")));
|
||||
let parts = st.table.as_ref().unwrap();
|
||||
assert_eq!((parts.header_fill, parts.band_fill, parts.band_size), (Some(Rgb(0xC0, 0, 0)), Some(Rgb(0xEE, 0xEE, 0xEE)), Some(2)));
|
||||
assert_eq!(parts.col_band_size, Some(1), "a new style bands one column at a time and writes it, so Word bands it too (#3415)");
|
||||
assert_eq!((parts.header_chr.bold, parts.band_chr.italic), (Some(false), Some(true)));
|
||||
assert!(parts.header_borders.is_some_and(|b| b.any_visible()));
|
||||
let t = back.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
|
||||
@@ -1833,6 +1833,74 @@ fn backspace_deletes_selected_columns_and_delete_clears_them() {
|
||||
assert_eq!(table_cells(&s), [["a1", "", "c1"], ["a2", "", "c2"], ["a3", "", "c3"]]);
|
||||
}
|
||||
|
||||
/// The first top-level table's cells, row by row, one entry per block: a paragraph's text, or
|
||||
/// `<table>` for a nested table.
|
||||
fn cell_blocks(s: &Session) -> Vec<Vec<Vec<String>>> {
|
||||
let block = |b: &std::sync::Arc<wordcraft_doc::Block>| b.as_para().map_or_else(|| "<table>".to_string(), |p| p.plain_text());
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.find_map(|b| b.as_table())
|
||||
.map(|t| t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().map(block).collect()).collect()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_on_whole_cells_leaves_one_empty_paragraph_in_each() {
|
||||
// a1 starts with a nested table and holds three paragraphs (centred, centred, right-aligned),
|
||||
// b2 holds a nested table after its text (#2802).
|
||||
let setup = || {
|
||||
let mut s = table_between(0, 0);
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 0, 0, 0], "off": 2}}));
|
||||
run(&mut s, "para.alignCenter", json!({}));
|
||||
typ(&mut s, "\nX\nY");
|
||||
run(&mut s, "para.alignRight", json!({}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
|
||||
typ(&mut s, "n");
|
||||
let b2 = |s: &Session| s.doc.body.get(1).and_then(|b| b.as_table()).map(|t| t.rows[1].cells[1].blocks.clone()).unwrap_or_default();
|
||||
let nested = b2(&s).iter().find(|b| b.as_table().is_some()).map(|b| (**b).clone()).expect("setup: a table nested in b2");
|
||||
s.doc.insert_block(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0]), nested).unwrap();
|
||||
s
|
||||
};
|
||||
let all: Vec<(usize, usize)> = (0..3).flat_map(|r| (0..3).map(move |c| (r, c))).collect();
|
||||
let cases: [(&str, (usize, usize), &str, Vec<(usize, usize)>); 5] = [
|
||||
("table.selectRow", (0, 0), "text.delete", vec![(0, 0), (0, 1), (0, 2)]),
|
||||
("table.selectColumn", (0, 0), "text.delete", vec![(0, 0), (1, 0), (2, 0)]),
|
||||
("table.selectRow", (1, 1), "text.delete", vec![(1, 0), (1, 1), (1, 2)]),
|
||||
("table.selectTable", (0, 0), "text.delete", all),
|
||||
// One cell of several: Backspace clears it too.
|
||||
("table.selectCell", (0, 0), "text.backspace", vec![(0, 0)]),
|
||||
];
|
||||
for (select, (r, c), key, cleared) in cases {
|
||||
let mut s = setup();
|
||||
let before = cell_blocks(&s);
|
||||
assert_eq!(before[0][0], ["<table>", "a1", "X", "Y"], "setup");
|
||||
assert!(before[1][1].len() > 2 && before[1][1].contains(&"<table>".to_string()), "setup: {:?}", before[1][1]);
|
||||
// a1's first paragraph is after the nested table.
|
||||
let first = usize::from((r, c) == (0, 0));
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, first], "off": 0}}));
|
||||
run(&mut s, select, json!({}));
|
||||
run(&mut s, key, json!({}));
|
||||
let mut want = before.clone();
|
||||
for &(r, c) in &cleared {
|
||||
want[r][c] = vec![String::new()];
|
||||
}
|
||||
assert_eq!(cell_blocks(&s), want, "{select} {key}");
|
||||
// The paragraph left keeps the cell's last paragraph's formatting (its mark ends the cell).
|
||||
if cleared.contains(&(0, 0)) {
|
||||
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0])).map(|p| p.props.align);
|
||||
assert_eq!(p, Some(Some(wordcraft_doc::props::Align::Right)), "{select} {key}");
|
||||
}
|
||||
assert!(s.sel.is_collapsed(), "{select} {key}");
|
||||
assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), cleared.first().copied(), "{select} {key}");
|
||||
// One undo brings everything back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(cell_blocks(&s), before, "{select} {key}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_on_a_selected_nested_table_removes_only_it() {
|
||||
let mut s = table_between(1, 1);
|
||||
|
||||
+319
-50
@@ -4,12 +4,14 @@
|
||||
//! HTML's implicit closes for `p`, `li`, `tr`, `td`/`th` and headings. It understands `p`, `div`,
|
||||
//! `h1`–`h6`, `b`/`strong`, `i`/`em`, `u`, `s`/`strike`/`del`, `sup`/`sub`, `code`/`kbd`, `a`
|
||||
//! (links and `id`/`name` anchors), `br`, `hr`, `ul`/`ol`/`li` (nested), `table`/`tr`/`td`/`th`
|
||||
//! (colspan/rowspan), `img` (`data:` URIs, other sources through a caller's [`ImageLoader`]), `blockquote`, `pre`, `font`, and the CSS
|
||||
//! (colspan/rowspan), `img` (`data:` URIs, other sources through a caller's [`ImageLoader`]), `blockquote`, `pre`, `font`, `ruby`
|
||||
//! (`rb`/`rt`/`rp`: furigana and other phonetic guides, #1681), and the CSS
|
||||
//! properties `color`, `background-color`, `font-weight`, `font-style`, `font-size`,
|
||||
//! `font-family`, `text-decoration`, `vertical-align`, `text-align`, `white-space` and page
|
||||
//! breaks. Scripts and styles are skipped; nesting is capped.
|
||||
//!
|
||||
//! Export writes clean semantic HTML with inline CSS and pictures as `data:` URIs.
|
||||
//! Export writes clean semantic HTML with inline CSS, pictures as `data:` URIs and ruby as
|
||||
//! `<ruby>`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -950,6 +952,21 @@ impl<'r> Builder<'r> {
|
||||
self.pre_tail = preserved;
|
||||
}
|
||||
|
||||
/// Add a `<ruby>` element's base text and reading as one inline (#1681). Half-marked ruby (no
|
||||
/// base, or no reading) is plain text instead: a guide that annotates nothing is no guide, and
|
||||
/// the text it held is never lost.
|
||||
fn push_ruby(&mut self, base: &str, ruby: &str) {
|
||||
let preserve = self.preserve();
|
||||
let text = |s: &str| if preserve { s.to_string() } else { collapse_ws(s) };
|
||||
let (base, ruby) = (text(base), text(ruby));
|
||||
let f = self.fmt();
|
||||
if base.is_empty() || ruby.is_empty() {
|
||||
self.start_para().push_text(if base.is_empty() { &ruby } else { &base }, &f);
|
||||
return;
|
||||
}
|
||||
self.start_para().inlines.push(Inline::Ruby { base, ruby, fmt: f });
|
||||
}
|
||||
|
||||
fn pre_fresh(&self) -> bool {
|
||||
self.stack.iter().rev().find(|e| e.kind == ElKind::Pre).is_some_and(|e| !e.used)
|
||||
}
|
||||
@@ -1345,6 +1362,13 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if name == "ruby" && !self_close {
|
||||
let r = read_ruby(&mut lx);
|
||||
if !b.in_head() {
|
||||
b.push_ruby(&r.base, &r.ruby);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if matches!(name.as_str(), "script" | "style" | "noscript" | "template" | "textarea") && !self_close {
|
||||
lx.skip_past_end_tag(&name);
|
||||
continue;
|
||||
@@ -1437,6 +1461,96 @@ fn skip_math(lx: &mut Lexer, first: Tok) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `<ruby>` element's base text and reading (#1681).
|
||||
#[derive(Default)]
|
||||
struct RubyText {
|
||||
base: String,
|
||||
ruby: String,
|
||||
}
|
||||
|
||||
/// Which part of a `<ruby>` element the text being read belongs to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum RubyPart {
|
||||
/// The text being annotated: an `<rb>` child, or the text outside `<rt>`/`<rp>`.
|
||||
Base,
|
||||
/// The reading: an `<rt>` child.
|
||||
Ruby,
|
||||
/// `<rp>` fallback parentheses, which are not text: browsers show them only where ruby isn't
|
||||
/// supported.
|
||||
Fallback,
|
||||
}
|
||||
|
||||
/// A block-level tag ends an open `<ruby>` implicitly, as it does in a browser.
|
||||
fn ends_ruby(t: &Tok) -> bool {
|
||||
match t {
|
||||
Tok::Start { name, .. } | Tok::End(name) => block_kind(name).is_some(),
|
||||
Tok::Text(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a `<ruby>` element's content: its base text from `<rb>` children (or the text outside
|
||||
/// them), its reading from `<rt>` children, and `<rp>` fallback parentheses dropped. Tolerant,
|
||||
/// like the rest of the reader: tags inside are ignored, an unclosed element ends at the next
|
||||
/// block-level tag (handed back to the caller) or with the input, and the tokens read are capped.
|
||||
fn read_ruby(lx: &mut Lexer) -> RubyText {
|
||||
/// Most tokens one `<ruby>` element is read from: a missing end tag must not scan the rest of
|
||||
/// a hostile file here.
|
||||
const MAX_TOKENS: usize = 4096;
|
||||
let mut out = RubyText::default();
|
||||
let mut part = RubyPart::Base;
|
||||
let mut nested = 0usize;
|
||||
let mut count = 0usize;
|
||||
while count < MAX_TOKENS {
|
||||
count += 1;
|
||||
let start = lx.pos;
|
||||
let Some(t) = lx.next() else { break };
|
||||
if nested == 0 && ends_ruby(&t) {
|
||||
// The boundary ends the element: give the token back to the main reader.
|
||||
lx.pos = start;
|
||||
break;
|
||||
}
|
||||
match t {
|
||||
Tok::Text(t) => match part {
|
||||
RubyPart::Base => out.base.push_str(&t),
|
||||
RubyPart::Ruby => out.ruby.push_str(&t),
|
||||
RubyPart::Fallback => {}
|
||||
},
|
||||
// The self-closing slash is ignored on HTML elements (`<rt/>` opens an `rt`), as in a
|
||||
// browser's parser.
|
||||
Tok::Start { name, self_close, .. } => match name.as_str() {
|
||||
"rb" => part = RubyPart::Base,
|
||||
"rt" => part = RubyPart::Ruby,
|
||||
"rp" => part = RubyPart::Fallback,
|
||||
"ruby" if !self_close => nested += 1,
|
||||
_ => {}
|
||||
},
|
||||
Tok::End(name) => match name.as_str() {
|
||||
"rb" | "rt" | "rp" => part = RubyPart::Base,
|
||||
"ruby" if nested > 0 => nested -= 1,
|
||||
"ruby" => break,
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Collapse white space inside ruby markup as body text does (HTML's white space only: an
|
||||
/// ideographic space is text), without the space the element's indentation leaves at the edges.
|
||||
fn collapse_ws(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{000C}') {
|
||||
if !out.ends_with(' ') {
|
||||
out.push(' ');
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out.trim_matches(' ').to_string()
|
||||
}
|
||||
|
||||
/// Parse HTML into a document.
|
||||
pub fn import(s: &str) -> Document {
|
||||
model::to_doc(&parse(s))
|
||||
@@ -1500,60 +1614,67 @@ fn text_html(t: &str, at_start: bool) -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// The tags that carry `f`'s character formatting: what opens them, and what closes them
|
||||
/// (innermost last, so the closes are written in reverse).
|
||||
fn fmt_tags(f: &Fmt) -> (String, Vec<&'static str>) {
|
||||
let mut open = String::new();
|
||||
let mut close: Vec<&str> = Vec::new();
|
||||
// A script or other non-web target is written as plain text (`is_safe_href`).
|
||||
if let Some(l) = f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) {
|
||||
open.push_str(&format!("<a href=\"{}\">", esc(l)));
|
||||
close.push("</a>");
|
||||
}
|
||||
let mut tag = |o: &str, c: &'static str| {
|
||||
open.push_str(o);
|
||||
close.push(c);
|
||||
};
|
||||
if f.bold {
|
||||
tag("<strong>", "</strong>");
|
||||
}
|
||||
if f.italic {
|
||||
tag("<em>", "</em>");
|
||||
}
|
||||
if f.underline {
|
||||
tag("<u>", "</u>");
|
||||
}
|
||||
if f.strike {
|
||||
tag("<s>", "</s>");
|
||||
}
|
||||
if f.sup {
|
||||
tag("<sup>", "</sup>");
|
||||
} else if f.sub {
|
||||
tag("<sub>", "</sub>");
|
||||
}
|
||||
if f.code {
|
||||
tag("<code>", "</code>");
|
||||
}
|
||||
let mut css = Vec::new();
|
||||
if let Some(c) = f.color {
|
||||
css.push(format!("color:#{}", c.hex()));
|
||||
}
|
||||
if let Some(c) = f.background {
|
||||
css.push(format!("background-color:#{}", c.hex()));
|
||||
}
|
||||
if let Some(s) = f.size {
|
||||
css.push(format!("font-size:{}pt", trim_num(s)));
|
||||
}
|
||||
if let Some(fam) = &f.font {
|
||||
css.push(format!("font-family:'{}'", esc(&fam.replace('\'', ""))));
|
||||
}
|
||||
if !css.is_empty() {
|
||||
open.push_str(&format!("<span style=\"{}\">", css.join(";")));
|
||||
close.push("</span>");
|
||||
}
|
||||
(open, close)
|
||||
}
|
||||
|
||||
/// Inline spans → HTML.
|
||||
pub fn inlines_html(inlines: &[Inline]) -> String {
|
||||
let mut o = String::new();
|
||||
for i in inlines.iter() {
|
||||
match i {
|
||||
Inline::Text(t, f) => {
|
||||
let mut open = String::new();
|
||||
let mut close: Vec<&str> = Vec::new();
|
||||
// A script or other non-web target is written as plain text (`is_safe_href`).
|
||||
if let Some(l) = f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) {
|
||||
open.push_str(&format!("<a href=\"{}\">", esc(l)));
|
||||
close.push("</a>");
|
||||
}
|
||||
let mut tag = |o: &str, c: &'static str| {
|
||||
open.push_str(o);
|
||||
close.push(c);
|
||||
};
|
||||
if f.bold {
|
||||
tag("<strong>", "</strong>");
|
||||
}
|
||||
if f.italic {
|
||||
tag("<em>", "</em>");
|
||||
}
|
||||
if f.underline {
|
||||
tag("<u>", "</u>");
|
||||
}
|
||||
if f.strike {
|
||||
tag("<s>", "</s>");
|
||||
}
|
||||
if f.sup {
|
||||
tag("<sup>", "</sup>");
|
||||
} else if f.sub {
|
||||
tag("<sub>", "</sub>");
|
||||
}
|
||||
if f.code {
|
||||
tag("<code>", "</code>");
|
||||
}
|
||||
let mut css = Vec::new();
|
||||
if let Some(c) = f.color {
|
||||
css.push(format!("color:#{}", c.hex()));
|
||||
}
|
||||
if let Some(c) = f.background {
|
||||
css.push(format!("background-color:#{}", c.hex()));
|
||||
}
|
||||
if let Some(s) = f.size {
|
||||
css.push(format!("font-size:{}pt", trim_num(s)));
|
||||
}
|
||||
if let Some(fam) = &f.font {
|
||||
css.push(format!("font-family:'{}'", esc(&fam.replace('\'', ""))));
|
||||
}
|
||||
if !css.is_empty() {
|
||||
open.push_str(&format!("<span style=\"{}\">", css.join(";")));
|
||||
close.push("</span>");
|
||||
}
|
||||
let (open, close) = fmt_tags(f);
|
||||
let at_start = o.is_empty();
|
||||
o.push_str(&open);
|
||||
o.push_str(&text_html(t, at_start));
|
||||
@@ -1561,6 +1682,17 @@ pub fn inlines_html(inlines: &[Inline]) -> String {
|
||||
o.push_str(c);
|
||||
}
|
||||
}
|
||||
// A phonetic guide: the base text with its reading in `<rt>` (#1681). HTML ruby has
|
||||
// no alignment or size, so the guide's own attributes stay in the document model.
|
||||
Inline::Ruby { base, ruby, fmt } => {
|
||||
let (open, close) = fmt_tags(fmt);
|
||||
let at_start = o.is_empty();
|
||||
o.push_str(&open);
|
||||
o.push_str(&format!("<ruby>{}<rt>{}</rt></ruby>", text_html(base, at_start), text_html(ruby, false)));
|
||||
for c in close.iter().rev() {
|
||||
o.push_str(c);
|
||||
}
|
||||
}
|
||||
Inline::Image(img) => {
|
||||
o.push_str(&format!(
|
||||
"<img src=\"data:{};base64,{}\" alt=\"{}\" width=\"{}\" height=\"{}\">",
|
||||
@@ -2060,4 +2192,141 @@ mod tests {
|
||||
let back = import(&html);
|
||||
assert_eq!(back.plain_text(Default::default()).replace('\u{a0}', " "), " lead");
|
||||
}
|
||||
|
||||
/// A document that reads "I read " then a phonetic guide over `base`, then " daily.".
|
||||
fn ruby_doc(base: &str, ruby: &str) -> Document {
|
||||
let props = wordcraft_doc::CharProps::default();
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("I read ", props.clone());
|
||||
let n = p.len();
|
||||
p.insert_object(n, crate::model::ruby_object(base.into(), ruby.into(), 20.0), &props).unwrap();
|
||||
let n = p.len();
|
||||
p.insert_text(n, " daily.", &props).unwrap();
|
||||
let mut d = Document::new();
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
d
|
||||
}
|
||||
|
||||
/// The first flow ruby: its base text and reading.
|
||||
fn ruby_of(f: &Flow) -> Option<(String, String)> {
|
||||
f.blocks.iter().find_map(|b| match b {
|
||||
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
|
||||
Inline::Ruby { base, ruby, .. } => Some((base.clone(), ruby.clone())),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The first document ruby: its base text and reading.
|
||||
fn doc_ruby(d: &Document) -> Option<(String, String)> {
|
||||
d.body.iter().find_map(|b| match &**b {
|
||||
wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o {
|
||||
wordcraft_doc::InlineObject::Ruby { base, ruby, .. } => Some((base.clone(), ruby.clone())),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The first document ruby: base size, ruby size, raise and alignment.
|
||||
fn doc_ruby_attrs(d: &Document) -> Option<(f32, f32, f32, wordcraft_doc::para::RubyAlign)> {
|
||||
d.body.iter().find_map(|b| match &**b {
|
||||
wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o {
|
||||
wordcraft_doc::InlineObject::Ruby { base_size, size, raise, align, .. } => Some((*base_size, *size, *raise, *align)),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_exports_as_ruby_markup() {
|
||||
// #1681: the base text with its reading in `<rt>`, not plain base text.
|
||||
let out = export(&ruby_doc("漢字", "かんじ"));
|
||||
assert!(out.contains("<p>I read <ruby>漢字<rt>かんじ</rt></ruby> daily.</p>"), "{out}");
|
||||
// The base and the reading are escaped like any other text.
|
||||
let out = export(&ruby_doc("A&B", "x<y"));
|
||||
assert!(out.contains("<ruby>A&B<rt>x<y</rt></ruby>"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_imports_with_its_surrounding_text() {
|
||||
let f = parse("<p>I read <ruby>漢字<rt>かんじ</rt></ruby> daily.</p>");
|
||||
assert_eq!(ruby_of(&f), Some(("漢字".into(), "かんじ".into())));
|
||||
assert_eq!(texts(&f), vec!["NormalNone:I read 漢字 daily."]);
|
||||
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
|
||||
assert!(matches!(&p.inlines[0], Inline::Text(t, _) if t == "I read "), "{:?}", p.inlines);
|
||||
assert!(matches!(p.inlines.last(), Some(Inline::Text(t, _)) if t == " daily."), "{:?}", p.inlines);
|
||||
// One document object, holding the base text and the reading.
|
||||
assert_eq!(doc_ruby(&crate::model::to_doc(&f)), Some(("漢字".into(), "かんじ".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_rb_and_rp_fallbacks_import_as_one_guide() {
|
||||
// `<rb>` holds the base text and `<rp>` the fallback parentheses for browsers without
|
||||
// ruby: the parentheses are not text, and the base pieces join.
|
||||
let f = parse("<p><ruby><rb>漢</rb><rb>字</rb><rp>(</rp><rt>かんじ</rt><rp>)</rp></ruby></p>");
|
||||
assert_eq!(ruby_of(&f), Some(("漢字".into(), "かんじ".into())));
|
||||
assert_eq!(texts(&f), vec!["NormalNone:漢字"]);
|
||||
assert_eq!(doc_ruby(&crate::model::to_doc(&f)), Some(("漢字".into(), "かんじ".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_import_uses_the_phonetic_guide_defaults() {
|
||||
// HTML states no alignment, size or raise: the Phonetic Guide's own defaults over a
|
||||
// 20 pt run (half the base size, raised clear of the base text, centred)…
|
||||
let d = crate::html::import("<p style='font-size:20pt'><ruby>漢字<rt>かんじ</rt></ruby></p>");
|
||||
let Some((base_size, size, raise, align)) = doc_ruby_attrs(&d) else { panic!("no ruby") };
|
||||
assert_eq!((base_size, size, align.ooxml()), (20.0, 10.0, "center"));
|
||||
assert!((raise - 18.0).abs() < 0.001, "{raise}");
|
||||
// …and the guide's 10.5 pt fallback base size when the source states none.
|
||||
let d = crate::html::import("<p><ruby>漢字<rt>かんじ</rt></ruby></p>");
|
||||
let Some((base_size, size, raise, _)) = doc_ruby_attrs(&d) else { panic!("no ruby") };
|
||||
assert_eq!((base_size, size), (10.5, 5.0));
|
||||
assert!((raise - 9.4).abs() < 0.001, "{raise}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_survives_an_html_round_trip() {
|
||||
let html = export(&ruby_doc("漢字", "かんじ"));
|
||||
assert_eq!(ruby_of(&parse(&html)), Some(("漢字".into(), "かんじ".into())));
|
||||
let back = crate::html::import(&html);
|
||||
assert_eq!(doc_ruby(&back), Some(("漢字".into(), "かんじ".into())));
|
||||
assert_eq!(back.plain_text(Default::default()), "I read 漢字 daily.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_keeps_the_run_formatting() {
|
||||
let props = wordcraft_doc::CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("I read ", props.clone());
|
||||
let n = p.len();
|
||||
p.insert_object(n, crate::model::ruby_object("漢字".into(), "かんじ".into(), 20.0), &props).unwrap();
|
||||
let mut d = Document::new();
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
assert!(export(&d).contains("<strong><ruby>漢字<rt>かんじ</rt></ruby></strong>"), "{}", export(&d));
|
||||
// Importing it back keeps the formatting for the base text and the reading.
|
||||
let f = parse("<p><strong><ruby>漢字<rt>かんじ</rt></ruby></strong></p>");
|
||||
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
|
||||
assert!(matches!(&p.inlines[0], Inline::Ruby { fmt, .. } if fmt.bold), "{:?}", p.inlines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ruby_keeps_its_text() {
|
||||
// No reading, an empty reading, a missing base, an unclosed element and a block boundary
|
||||
// closing it: none of them panics, and the text inside is kept.
|
||||
let cases: [(&str, &[&str]); 7] = [
|
||||
("<p>a <ruby>漢字</ruby> b</p>", &["NormalNone:a 漢字 b"]),
|
||||
("<p>a <ruby>漢字<rt></rt></ruby> b</p>", &["NormalNone:a 漢字 b"]),
|
||||
("<p>a <ruby><rt>かんじ</rt></ruby> b</p>", &["NormalNone:a かんじ b"]),
|
||||
("<p>a <ruby><rt></rt></ruby> b</p>", &["NormalNone:a b"]),
|
||||
("<ruby>", &[]),
|
||||
("<p>a <ruby>漢字<rt>かんじ</rt> b</p><p>next</p>", &["NormalNone:a 漢字 b", "NormalNone:next"]),
|
||||
("<p>a <ruby>漢字<rt>かんじ</rt></p><p>next</p>", &["NormalNone:a 漢字", "NormalNone:next"]),
|
||||
];
|
||||
for (src, want) in cases {
|
||||
assert_eq!(texts(&parse(src)), want, "{src}");
|
||||
}
|
||||
// Ruby in ruby: the inner element's text joins the outer guide's.
|
||||
assert_eq!(ruby_of(&parse("<p><ruby>漢字<ruby>字<rt>じ</rt></ruby><rt>かんじ</rt></ruby></p>")), Some(("漢字字".into(), "じかんじ".into())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,11 @@ impl Writer {
|
||||
}
|
||||
Inline::Anchor(name) => o.push_str(&format!("\\hypertarget{{{}}}{{}}", escape_url(name))),
|
||||
Inline::Figure(_) => {}
|
||||
// LaTeX keeps no ruby (no package): its base text, in the run's formatting.
|
||||
Inline::Ruby { base, fmt, .. } => {
|
||||
let s = self.span(base, fmt);
|
||||
o.push_str(&s);
|
||||
}
|
||||
Inline::Equation { linear, display } => {
|
||||
self.uses.math = true;
|
||||
let src = linear_to_latex(linear);
|
||||
@@ -1669,11 +1674,14 @@ fn parse_color(model: Option<&str>, spec: &str) -> Option<Rgb> {
|
||||
|
||||
/// A TeX length in points (`3cm`, `0.4\linewidth`), if it is one.
|
||||
fn length_pt(s: &str) -> Option<f32> {
|
||||
let s = s.trim();
|
||||
let s = s.trim().strip_prefix('+').unwrap_or(s.trim());
|
||||
let split = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len());
|
||||
let (n, unit) = s.split_at(split);
|
||||
let n: f32 = if n.is_empty() { 1.0 } else { n.parse().ok()? };
|
||||
let k = match unit.trim() {
|
||||
let unit = unit.trim();
|
||||
// Macro names are case-sensitive (`\TEXTWIDTH` is not `\textwidth`); unit keywords are not.
|
||||
let unit = if unit.starts_with('\\') { unit.to_owned() } else { unit.to_ascii_lowercase() };
|
||||
let k = match unit.as_str() {
|
||||
"pt" => 1.0,
|
||||
"bp" => 72.27 / 72.0,
|
||||
"in" => 72.27,
|
||||
@@ -2804,6 +2812,15 @@ mod tests {
|
||||
assert!(widths.len() <= wordcraft_doc::table::MAX_COLS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latex_length_pt_case_and_plus() {
|
||||
assert_eq!(length_pt("12pt"), Some(12.0));
|
||||
assert_eq!(length_pt("12PT"), Some(12.0));
|
||||
assert_eq!(length_pt("+12pt"), Some(12.0));
|
||||
assert_eq!(length_pt("1IN"), Some(72.27));
|
||||
assert_eq!(length_pt("+2cm"), Some(56.9));
|
||||
}
|
||||
|
||||
const ARTICLE: &str = r#"\documentclass[11pt]{article}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage{amsmath} % maths
|
||||
|
||||
@@ -1279,7 +1279,9 @@ fn inlines_md_table(inlines: &[Inline]) -> String {
|
||||
fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String {
|
||||
// Split into (text, fmt) pieces with whitespace moved outside emphasis.
|
||||
let mut pieces: Vec<(String, Fmt, Option<String>)> = Vec::new();
|
||||
let inlines = crate::model::equations_as_text(inlines);
|
||||
let eq = crate::model::equations_as_text(inlines);
|
||||
// Markdown keeps no ruby: its base text goes through the text path below.
|
||||
let inlines = crate::model::ruby_as_text(&eq);
|
||||
for i in inlines.iter() {
|
||||
match i {
|
||||
Inline::Text(t, f) => {
|
||||
@@ -1312,6 +1314,8 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String {
|
||||
}
|
||||
Inline::Figure(alt) => pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))),
|
||||
Inline::Equation { .. } => {}
|
||||
// Read as its base text by `ruby_as_text` above.
|
||||
Inline::Ruby { .. } => {}
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
|
||||
+163
-13
@@ -5,7 +5,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::numbering::{AbstractNum, Level, ListKind, Num, levels_for};
|
||||
use wordcraft_doc::para::{InlineObject, OBJ, PAGE_BREAK, Run};
|
||||
use wordcraft_doc::para::{InlineObject, OBJ, PAGE_BREAK, RubyAlign, Run, ruby_raise};
|
||||
use wordcraft_doc::props::{
|
||||
Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, PropChange, RowProps, TextColor, Underline, VMerge, VertAlign,
|
||||
};
|
||||
@@ -91,6 +91,34 @@ pub enum Inline {
|
||||
linear: String,
|
||||
display: bool,
|
||||
},
|
||||
/// Ruby (phonetic guide): `ruby` text (furigana, pinyin…) over `base` text, in the formatting
|
||||
/// of the run it sits in (#1681). Formats without ruby write the base text
|
||||
/// ([`ruby_as_text`]).
|
||||
Ruby {
|
||||
base: String,
|
||||
ruby: String,
|
||||
fmt: Fmt,
|
||||
},
|
||||
}
|
||||
|
||||
/// Base text size (points) a phonetic guide falls back to when the source states none: the
|
||||
/// Phonetic Guide command's own fallback for an unresolved size
|
||||
/// (`crates/engine/src/cmd/phonetic.rs`).
|
||||
pub const RUBY_BASE_SIZE: f32 = 10.5;
|
||||
|
||||
/// Ruby text size for a base of `base_size` points: half the base size, rounded down to a half
|
||||
/// point, as the Phonetic Guide command sets it.
|
||||
fn ruby_default_size(base_size: f32) -> f32 {
|
||||
(base_size.floor() / 2.0).max(1.0)
|
||||
}
|
||||
|
||||
/// The ruby a format without its own ruby markup gets: a [`InlineObject::Ruby`] over `base` with
|
||||
/// the Phonetic Guide's defaults (centred, half the base size, raised clear of the base text).
|
||||
pub fn ruby_object(base: String, ruby: String, base_size: f32) -> InlineObject {
|
||||
let base_size = if base_size.is_finite() { base_size.clamp(1.0, 1584.0) } else { RUBY_BASE_SIZE };
|
||||
let size = ruby_default_size(base_size);
|
||||
let raise = ruby_raise(0.0, base_size, size).clamp(0.0, 1584.0);
|
||||
InlineObject::Ruby { base, ruby, align: RubyAlign::default(), size, raise, base_size, lang: String::new(), props: Default::default() }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -170,12 +198,13 @@ impl Para {
|
||||
}
|
||||
self.inlines.push(Inline::Text(s.to_string(), f.clone()));
|
||||
}
|
||||
/// The plain text (images and anchors left out; equations as their linear text).
|
||||
/// The plain text (images and anchors left out; equations as their linear text, ruby as its
|
||||
/// base text).
|
||||
pub fn text(&self) -> String {
|
||||
let mut s = String::new();
|
||||
for i in &self.inlines {
|
||||
match i {
|
||||
Inline::Text(t, _) | Inline::Equation { linear: t, .. } => s.push_str(t),
|
||||
Inline::Text(t, _) | Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } => s.push_str(t),
|
||||
Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -191,7 +220,7 @@ impl Para {
|
||||
Some(font) if wordcraft_doc::symbol_fonts::is_symbol_font(font) => s.push_str(&wordcraft_doc::symbol_fonts::map_text(font, t)),
|
||||
_ => s.push_str(t),
|
||||
},
|
||||
Inline::Equation { linear: t, .. } => s.push_str(t),
|
||||
Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } => s.push_str(t),
|
||||
Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -363,6 +392,22 @@ pub fn equations_as_text(inlines: &[Inline]) -> std::borrow::Cow<'_, [Inline]> {
|
||||
std::borrow::Cow::Owned(p.inlines)
|
||||
}
|
||||
|
||||
/// `inlines` with ruby as its base text, for formats that have no ruby markup: the base text (and
|
||||
/// everything around it) keeps its formatting, and the reading is left out.
|
||||
pub fn ruby_as_text(inlines: &[Inline]) -> std::borrow::Cow<'_, [Inline]> {
|
||||
if !inlines.iter().any(|i| matches!(i, Inline::Ruby { .. })) {
|
||||
return std::borrow::Cow::Borrowed(inlines);
|
||||
}
|
||||
let mut p = Para::default();
|
||||
for i in inlines {
|
||||
match i {
|
||||
Inline::Ruby { base, fmt, .. } => p.push_text(base, fmt),
|
||||
other => p.inlines.push(other.clone()),
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(p.inlines)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Flow → Document
|
||||
|
||||
@@ -568,6 +613,20 @@ impl<'a> Builder<'a> {
|
||||
append(&mut out, "", &CharProps::default(), Some(obj));
|
||||
}
|
||||
}
|
||||
Inline::Ruby { base, ruby, fmt } => {
|
||||
let (base, ruby) = (clean_text(base), clean_text(ruby));
|
||||
let cp = char_props(fmt);
|
||||
// A source without a base or without a reading (malformed markup) is just its
|
||||
// text: a guide that annotates nothing is no guide.
|
||||
if base.is_empty() || ruby.is_empty() {
|
||||
append(&mut out, if base.is_empty() { &ruby } else { &base }, &cp, None);
|
||||
} else {
|
||||
// Ruby attributes a format without them doesn't state (HTML): the
|
||||
// Phonetic Guide's defaults for a new guide over this run's text.
|
||||
let obj = ruby_object(base, ruby, cp.size.unwrap_or(RUBY_BASE_SIZE));
|
||||
append(&mut out, "", &cp, Some(obj));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.mark.ins = p.mark_ins.as_deref().map(|m| self.revision(RevisionKind::Insert, m));
|
||||
@@ -959,14 +1018,21 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec<Para> {
|
||||
continue;
|
||||
}
|
||||
match obj {
|
||||
Some(InlineObject::Image { media, w, h, alt, .. }) => {
|
||||
Some(o @ InlineObject::Image { media, w, h, alt, .. }) => {
|
||||
if !buf.is_empty() {
|
||||
out.push_text(&std::mem::take(&mut buf), &f);
|
||||
}
|
||||
// A picture marked decorative keeps its description in the document, but
|
||||
// exports with none (`alt=""`), so screen readers skip it.
|
||||
let alt = if o.is_decorative() { "" } else { alt.as_str() };
|
||||
match doc.media.get(media) {
|
||||
Some(data) => {
|
||||
out.inlines.push(Inline::Image(Img { data: data.clone(), ext: media_ext(media, data), w: *w, h: *h, alt: alt.clone() }))
|
||||
}
|
||||
Some(data) => out.inlines.push(Inline::Image(Img {
|
||||
data: data.clone(),
|
||||
ext: media_ext(media, data),
|
||||
w: *w,
|
||||
h: *h,
|
||||
alt: alt.to_string(),
|
||||
})),
|
||||
None if !alt.is_empty() => buf.push_str(alt),
|
||||
None => {}
|
||||
}
|
||||
@@ -988,6 +1054,14 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec<Para> {
|
||||
let linear = if linear.is_empty() { wordcraft_doc::math::to_linear(&math.nodes) } else { linear.clone() };
|
||||
out.inlines.push(Inline::Equation { linear, display: *display });
|
||||
}
|
||||
Some(InlineObject::Ruby { base, ruby, .. }) => {
|
||||
if !buf.is_empty() {
|
||||
out.push_text(&std::mem::take(&mut buf), &f);
|
||||
}
|
||||
// The base text and reading, in the run's formatting; the guide's own
|
||||
// attributes (alignment, size, raise) stay in the document model.
|
||||
out.inlines.push(Inline::Ruby { base: base.clone(), ruby: ruby.clone(), fmt: f.clone() });
|
||||
}
|
||||
Some(InlineObject::BookmarkStart { name }) if name != "_GoBack" => {
|
||||
if !buf.is_empty() {
|
||||
out.push_text(&std::mem::take(&mut buf), &f);
|
||||
@@ -1290,20 +1364,60 @@ impl ListCounter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a CSS / HTML colour: `#rgb`, `#rrggbb`, `rgb(r,g,b)` or a basic name.
|
||||
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> Rgb {
|
||||
let h = (h / 360.0).rem_euclid(1.0);
|
||||
let s = s.clamp(0.0, 1.0);
|
||||
let l = l.clamp(0.0, 1.0);
|
||||
if s == 0.0 {
|
||||
let v = (l * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
return Rgb(v, v, v);
|
||||
}
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
let hue_to_rgb = |mut t: f64| -> f64 {
|
||||
if t < 0.0 {
|
||||
t += 1.0;
|
||||
}
|
||||
if t > 1.0 {
|
||||
t -= 1.0;
|
||||
}
|
||||
if t < 1.0 / 6.0 {
|
||||
return p + (q - p) * 6.0 * t;
|
||||
}
|
||||
if t < 1.0 / 2.0 {
|
||||
return q;
|
||||
}
|
||||
if t < 2.0 / 3.0 {
|
||||
return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
||||
}
|
||||
p
|
||||
};
|
||||
let r = (hue_to_rgb(h + 1.0 / 3.0) * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
let g = (hue_to_rgb(h) * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
let b = (hue_to_rgb(h - 1.0 / 3.0) * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
Rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// Parse a CSS / HTML colour: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(...)`, `hsl(...)` or a basic name.
|
||||
pub fn parse_color(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
if let Some(h) = s.strip_prefix('#') {
|
||||
if h.len() == 3 && h.is_ascii() {
|
||||
if (h.len() == 3 || h.len() == 4) && h.is_ascii() {
|
||||
let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17);
|
||||
return Some(Rgb(d(0)?, d(1)?, d(2)?));
|
||||
}
|
||||
if h.len() == 8 && h.is_ascii() {
|
||||
let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
|
||||
return Some(Rgb(p(0)?, p(2)?, p(4)?));
|
||||
}
|
||||
return Rgb::parse(h);
|
||||
}
|
||||
if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) {
|
||||
let v: Vec<u8> = inner
|
||||
.trim_end_matches(')')
|
||||
.split(',')
|
||||
let inner = inner.trim_end_matches(')').trim();
|
||||
let before_slash = inner.split('/').next().unwrap_or(inner);
|
||||
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
|
||||
let v: Vec<u8> = parts
|
||||
.iter()
|
||||
.take(3)
|
||||
.filter_map(|x| {
|
||||
let x = x.trim();
|
||||
@@ -1319,6 +1433,35 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if let Some(inner) = s.strip_prefix("hsl(").or_else(|| s.strip_prefix("hsla(")) {
|
||||
let inner = inner.trim_end_matches(')').trim();
|
||||
let before_slash = inner.split('/').next().unwrap_or(inner);
|
||||
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let h_str = parts[0].trim();
|
||||
let h: f64 = if let Some(deg) = h_str.strip_suffix("deg") {
|
||||
deg.trim().parse().ok()?
|
||||
} else if let Some(rad) = h_str.strip_suffix("rad") {
|
||||
rad.trim().parse::<f64>().ok()? * 180.0 / std::f64::consts::PI
|
||||
} else if let Some(turn) = h_str.strip_suffix("turn") {
|
||||
turn.trim().parse::<f64>().ok()? * 360.0
|
||||
} else {
|
||||
h_str.parse().ok()?
|
||||
};
|
||||
let parse_pct = |x: &str| -> Option<f64> {
|
||||
let x = x.trim();
|
||||
if let Some(p) = x.strip_suffix('%') {
|
||||
Some((p.trim().parse::<f64>().ok()? / 100.0).clamp(0.0, 1.0))
|
||||
} else {
|
||||
Some(x.parse::<f64>().ok()?.clamp(0.0, 1.0))
|
||||
}
|
||||
};
|
||||
let s = parse_pct(parts[1])?;
|
||||
let l = parse_pct(parts[2])?;
|
||||
return Some(hsl_to_rgb(h, s, l));
|
||||
}
|
||||
Some(match s.as_str() {
|
||||
"black" => Rgb(0, 0, 0),
|
||||
"white" => Rgb(255, 255, 255),
|
||||
@@ -1377,8 +1520,15 @@ mod tests {
|
||||
#[test]
|
||||
fn colors() {
|
||||
assert_eq!(parse_color("#f00"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(parse_color("#f00f"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(parse_color("#0080FF"), Some(Rgb(0, 128, 255)));
|
||||
assert_eq!(parse_color("#0080ff80"), Some(Rgb(0, 128, 255)));
|
||||
assert_eq!(parse_color("rgb(1, 2, 3)"), Some(Rgb(1, 2, 3)));
|
||||
assert_eq!(parse_color("rgb(1 2 3)"), Some(Rgb(1, 2, 3)));
|
||||
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(1, 2, 3)));
|
||||
assert_eq!(parse_color("hsl(0, 100%, 50%)"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(parse_color("hsl(120deg 100% 50%)"), Some(Rgb(0, 255, 0)));
|
||||
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(0, 0, 255)));
|
||||
assert_eq!(parse_color("navy"), Some(Rgb(0, 0, 128)));
|
||||
assert_eq!(parse_color("#zz"), None);
|
||||
assert_eq!(parse_color("rgb(1)"), None);
|
||||
|
||||
@@ -349,7 +349,9 @@ impl Writer {
|
||||
|
||||
fn plain_inlines(&mut self, inl: &[Inline], out: &mut String) {
|
||||
let mut prev_space = true;
|
||||
let inl = model::equations_as_text(inl);
|
||||
let eq = model::equations_as_text(inl);
|
||||
// ODF has no ruby: its base text goes through the text path below.
|
||||
let inl = model::ruby_as_text(&eq);
|
||||
let last = inl.len().saturating_sub(1);
|
||||
for (idx, i) in inl.iter().enumerate() {
|
||||
match i {
|
||||
@@ -426,6 +428,8 @@ impl Writer {
|
||||
prev_space = false;
|
||||
}
|
||||
Inline::Anchor(a) => out.push_str(&format!("<text:bookmark text:name=\"{}\"/>", x(a))),
|
||||
// Read as its base text by `ruby_as_text` above.
|
||||
Inline::Ruby { .. } => {}
|
||||
Inline::Figure(_) | Inline::Equation { .. } => {}
|
||||
}
|
||||
}
|
||||
@@ -450,7 +454,8 @@ impl Writer {
|
||||
"</text:p>"
|
||||
}
|
||||
};
|
||||
let tracked = paras.len() > 1 || p.inlines.iter().any(|i| matches!(i, Inline::Text(_, f) if f.ins.is_some() || f.del.is_some()));
|
||||
let tracked = paras.len() > 1
|
||||
|| p.inlines.iter().any(|i| matches!(i, Inline::Text(_, f) | Inline::Ruby { fmt: f, .. } if f.ins.is_some() || f.del.is_some()));
|
||||
if tracked {
|
||||
let mut items = Vec::new();
|
||||
for (k, q) in paras.iter().enumerate() {
|
||||
@@ -729,7 +734,7 @@ impl Item<'_> {
|
||||
/// Its tracked insertion and deletion.
|
||||
fn marks(&self) -> (Option<&RevMark>, Option<&RevMark>) {
|
||||
match self {
|
||||
Item::Inl(Inline::Text(_, f)) => (f.ins.as_ref(), f.del.as_ref()),
|
||||
Item::Inl(Inline::Text(_, f) | Inline::Ruby { fmt: f, .. }) => (f.ins.as_ref(), f.del.as_ref()),
|
||||
Item::Inl(_) => (None, None),
|
||||
Item::Mark(p) => (p.mark_ins.as_deref(), p.mark_del.as_deref()),
|
||||
}
|
||||
@@ -1089,7 +1094,7 @@ fn column_sep(a: &Attrs) -> bool {
|
||||
|
||||
/// Length → points.
|
||||
fn length(v: &str) -> Option<f32> {
|
||||
let v = v.trim();
|
||||
let v = v.trim().to_ascii_lowercase();
|
||||
let num = |s: &str| s.trim().parse::<f32>().ok().filter(|x| x.is_finite());
|
||||
if let Some(n) = v.strip_suffix("pt") {
|
||||
num(n)
|
||||
@@ -1104,7 +1109,7 @@ fn length(v: &str) -> Option<f32> {
|
||||
} else if let Some(n) = v.strip_suffix("px") {
|
||||
num(n).map(|x| x * 0.75)
|
||||
} else {
|
||||
num(v)
|
||||
num(&v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2651,6 +2656,12 @@ mod tests {
|
||||
assert_eq!(length("1in"), Some(72.0));
|
||||
assert_eq!(length("12pt"), Some(12.0));
|
||||
assert!((length("2.54cm").unwrap() - 72.0).abs() < 0.01);
|
||||
assert_eq!(length("1IN"), Some(72.0));
|
||||
assert_eq!(length("12PT"), Some(12.0));
|
||||
assert!((length("2.54CM").unwrap() - 72.0).abs() < 0.01);
|
||||
assert!((length("25.4MM").unwrap() - 72.0).abs() < 0.01);
|
||||
assert_eq!(length("1pc"), Some(12.0));
|
||||
assert_eq!(length("10PX"), Some(7.5));
|
||||
assert_eq!(length("x"), None);
|
||||
}
|
||||
|
||||
@@ -3415,6 +3426,25 @@ mod tests {
|
||||
assert_eq!(rev_runs(&doc)[2].1, Some(wordcraft_doc::RevisionKind::Insert));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inserted_ruby_keeps_its_tracked_insertion() {
|
||||
// ODF has no ruby: its base text is written as text, and the insertion on the ruby must
|
||||
// still be a change range around that text, not plain untracked text.
|
||||
use wordcraft_doc::RevisionKind::Insert;
|
||||
let props = wordcraft_doc::props::CharProps::default();
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("before ", props.clone());
|
||||
let n = p.len();
|
||||
let mut ins = props.clone();
|
||||
ins.ins = Some(0);
|
||||
p.insert_object(n, crate::model::ruby_object("漢字".into(), "かんじ".into(), 20.0), &ins).unwrap();
|
||||
let mut d = Document::new();
|
||||
d.settings.track_changes = true;
|
||||
d.revisions.push(wordcraft_doc::Revision::new(Insert, "Alice", "2024-03-01T10:00:00"));
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let back = import(&export(&d).unwrap()).unwrap();
|
||||
assert_eq!(rev_runs(&back), vec![(vec![("before ".to_string(), None), ("漢字".to_string(), Some(Insert))], None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_tracked_changes_are_bounded() {
|
||||
// Unknown and duplicate ids, a change-end without a start, nested regions, a deletion
|
||||
|
||||
+285
-19
@@ -10,8 +10,11 @@
|
||||
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers,
|
||||
//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN`
|
||||
//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`, `\mac`, `\pc`, `\pca`) or
|
||||
//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman…;
|
||||
//! Windows-1252 when none is named), the formatting words above, stylesheet-based
|
||||
//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman, the
|
||||
//! East Asian multibyte pages 932/936/949/950…; Windows-1252 when none is named). A multibyte
|
||||
//! code page's bytes stream through one decoder, so a lead byte and its trail byte decode
|
||||
//! together and a run that looks like a UTF-8 BOM is not mistaken for one; the formatting words
|
||||
//! above, stylesheet-based
|
||||
//! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and
|
||||
//! tables. Group nesting and picture sizes are capped.
|
||||
|
||||
@@ -27,6 +30,12 @@ const MAX_GROUPS: usize = 512;
|
||||
const MAX_PICT: usize = 64 << 20;
|
||||
/// Most sections we read (`\sect`); later breaks end paragraphs only.
|
||||
const MAX_SECTIONS: usize = 10_000;
|
||||
/// The encoding of an East Asian multibyte code page (932 Shift-JIS, 936 GBK, 949 UHC, 950 Big5),
|
||||
/// which [`Reader::finish_multibyte`] decodes a run with; `None` for the single-byte code pages.
|
||||
fn multibyte_encoding(code_page: u16) -> Option<&'static encoding_rs::Encoding> {
|
||||
let label = wordcraft_doc::encoding::multibyte_encoding_name(code_page)?;
|
||||
encoding_rs::Encoding::for_label(label.as_bytes())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Export
|
||||
@@ -403,6 +412,8 @@ impl Writer {
|
||||
let a = esc(a);
|
||||
out.push_str(&format!("{{\\*\\bkmkstart {a}}}{{\\*\\bkmkend {a}}}"));
|
||||
}
|
||||
// RTF keeps no ruby yet: its base text, in the run's formatting.
|
||||
Inline::Ruby { base, fmt, .. } => self.run(base, fmt, out),
|
||||
Inline::Figure(_) | Inline::Equation { .. } => {}
|
||||
}
|
||||
}
|
||||
@@ -1144,6 +1155,11 @@ struct Reader {
|
||||
rev_name: String,
|
||||
skip_chars: usize,
|
||||
pending_high: Option<u16>,
|
||||
/// A streaming decoder for the current multibyte code page, and the code page it was made for.
|
||||
/// A lead byte and its trail byte decode together even when a text word, a group boundary or the
|
||||
/// end of a chunk falls between them, so a run of any length never splits a character.
|
||||
mb_decoder: Option<encoding_rs::Decoder>,
|
||||
mb_cp: u16,
|
||||
// Output.
|
||||
body: Vec<FBlock>,
|
||||
para: Para,
|
||||
@@ -1291,6 +1307,8 @@ impl Reader {
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
// A group end decodes the multibyte bytes read in it, before the state is restored.
|
||||
self.finish_multibyte();
|
||||
self.skip_chars = 0;
|
||||
if self.overflow > 0 {
|
||||
self.overflow -= 1;
|
||||
@@ -1378,11 +1396,126 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte of 8-bit text (`\'hh` or raw) as a char, in the current font's code page, else the
|
||||
/// default font's, else the document's.
|
||||
fn byte_char(&self, b: u8) -> char {
|
||||
let cp = self.st.cp.or_else(|| self.font_cps.get(&self.deff).copied()).unwrap_or(if self.ansicpg == 0 { 1252 } else { self.ansicpg });
|
||||
wordcraft_doc::encoding::code_page_byte(cp, b)
|
||||
/// The code page 8-bit text is read in: the current font's, else the default font's, else the
|
||||
/// document's (Windows-1252 when none is named).
|
||||
fn code_page(&self) -> u16 {
|
||||
self.st.cp.or_else(|| self.font_cps.get(&self.deff).copied()).unwrap_or(if self.ansicpg == 0 { 1252 } else { self.ansicpg })
|
||||
}
|
||||
|
||||
/// A byte of 8-bit text (`\'hh` or raw): a single-byte code page decodes it at once; a
|
||||
/// multibyte one is fed to a streaming decoder ([`Reader::finish_multibyte`]), so a lead byte decodes
|
||||
/// together with its trail byte.
|
||||
fn byte(&mut self, b: u8) {
|
||||
if self.skip_chars > 0 {
|
||||
self.skip_chars -= 1;
|
||||
return;
|
||||
}
|
||||
let cp = self.code_page();
|
||||
let Some(encoding) = multibyte_encoding(cp) else {
|
||||
self.finish_multibyte();
|
||||
let c = wordcraft_doc::encoding::code_page_byte(cp, b);
|
||||
self.ch(c);
|
||||
return;
|
||||
};
|
||||
// A code page switch ends the previous run (a lead byte left then becomes U+FFFD, as Word
|
||||
// writes it) before the new decoder starts.
|
||||
if self.mb_cp != cp || self.mb_decoder.is_none() {
|
||||
self.finish_multibyte();
|
||||
self.mb_cp = cp;
|
||||
self.mb_decoder = Some(encoding.new_decoder_without_bom_handling());
|
||||
}
|
||||
self.decode_run(&[b]);
|
||||
}
|
||||
|
||||
/// Feed 8-bit text bytes to the open multibyte decoder and append what it emits. A single
|
||||
/// `\'hh` byte decodes into a stack buffer (no heap allocation per byte of escaped text); a
|
||||
/// raw run decodes in one call, so a long ASCII run in a Japanese, Chinese or Korean document
|
||||
/// costs one append instead of one per byte. The destination's spare capacity is all
|
||||
/// `decode_to_*` writes into, so a full buffer is drained and decoding continues.
|
||||
fn decode_run(&mut self, bytes: &[u8]) {
|
||||
// The decoder is taken out so the text it produced can be appended (which borrows the
|
||||
// reader) before it goes back.
|
||||
let Some(mut decoder) = self.mb_decoder.take() else { return };
|
||||
let mut read = 0;
|
||||
if bytes.len() <= 16 {
|
||||
// A character is at most 4 UTF-8 bytes, and a byte may also replace a pending lead
|
||||
// byte (3 more), so one escaped byte always fits the stack buffer.
|
||||
let mut buf = [0u8; 16];
|
||||
let (result, got, written, _) = decoder.decode_to_utf8(bytes, &mut buf, false);
|
||||
read = got;
|
||||
let full = matches!(result, encoding_rs::CoderResult::OutputFull);
|
||||
if written > 0
|
||||
&& let Ok(s) = std::str::from_utf8(&buf[..written])
|
||||
{
|
||||
self.text(s);
|
||||
}
|
||||
if !full {
|
||||
self.mb_decoder = Some(decoder);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut out = String::with_capacity(bytes.len().saturating_mul(2));
|
||||
let mut rest = bytes.get(read..).unwrap_or_default();
|
||||
loop {
|
||||
let (result, got, _) = decoder.decode_to_string(rest, &mut out, false);
|
||||
rest = rest.get(got..).unwrap_or_default();
|
||||
if !matches!(result, encoding_rs::CoderResult::OutputFull) {
|
||||
break;
|
||||
}
|
||||
out.reserve(16.max(rest.len().saturating_mul(2)));
|
||||
}
|
||||
self.mb_decoder = Some(decoder);
|
||||
if !out.is_empty() {
|
||||
self.text(&out);
|
||||
}
|
||||
}
|
||||
|
||||
/// A run of raw 8-bit text from the file (not `\'hh` escapes): a single-byte code page maps
|
||||
/// each byte to a char; a multibyte one decodes the whole run in one pass, so a long ASCII run
|
||||
/// in a Japanese, Chinese or Korean document costs one decode and one append instead of one
|
||||
/// per byte.
|
||||
///
|
||||
/// The decoder is created without BOM handling: the code page comes from `\ansicpg`, `\cpg` or
|
||||
/// `\fcharset` and must not change. With BOM sniffing, a run that starts `EF BB BF` in GBK,
|
||||
/// Big5 or UHC (a valid character pair followed by a lead byte) would be dropped as a UTF-8
|
||||
/// BOM and the rest decoded as UTF-8.
|
||||
fn run(&mut self, chunk: &[u8]) {
|
||||
if self.skip_chars > 0 {
|
||||
for b in chunk {
|
||||
self.byte(*b);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let cp = self.code_page();
|
||||
let Some(encoding) = multibyte_encoding(cp) else {
|
||||
self.finish_multibyte();
|
||||
let mut out = String::with_capacity(chunk.len());
|
||||
for b in chunk {
|
||||
out.push(wordcraft_doc::encoding::code_page_byte(cp, *b));
|
||||
}
|
||||
if !out.is_empty() {
|
||||
self.text(&out);
|
||||
}
|
||||
return;
|
||||
};
|
||||
if self.mb_cp != cp || self.mb_decoder.is_none() {
|
||||
self.finish_multibyte();
|
||||
self.mb_cp = cp;
|
||||
self.mb_decoder = Some(encoding.new_decoder_without_bom_handling());
|
||||
}
|
||||
self.decode_run(chunk);
|
||||
}
|
||||
|
||||
/// Ends the current multibyte run: the streaming decoder emits whatever it still holds (a lone
|
||||
/// lead byte becomes U+FFFD, as Word writes it) and is dropped. A code page switch, a single-byte
|
||||
/// run, a group end and the end of the document finish the run this way.
|
||||
fn finish_multibyte(&mut self) {
|
||||
let Some(mut decoder) = self.mb_decoder.take() else { return };
|
||||
let mut out = String::with_capacity(16);
|
||||
let _ = decoder.decode_to_string(&[], &mut out, true);
|
||||
if !out.is_empty() {
|
||||
self.text(&out);
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_style(&mut self) {
|
||||
@@ -1571,6 +1704,7 @@ impl Reader {
|
||||
self.skip_chars -= 1;
|
||||
return;
|
||||
}
|
||||
self.finish_multibyte();
|
||||
let mut buf = [0u8; 4];
|
||||
self.text(c.encode_utf8(&mut buf));
|
||||
}
|
||||
@@ -1728,6 +1862,9 @@ impl Reader {
|
||||
}
|
||||
|
||||
fn word(&mut self, w: &str, p: Option<i32>, starred: bool, first_in_group: bool) {
|
||||
// Every control word ends a multibyte run: the code page it is read in may change here
|
||||
// (`\f`, `\fcharset`, `\cpg`, `\ansicpg`, `\pard`), and `\par` must see the text before it.
|
||||
self.finish_multibyte();
|
||||
let on = p != Some(0);
|
||||
let pv = p.unwrap_or(0);
|
||||
if self.st.dest == Dest::Skip && !(self.in_info() && matches!(w, "title" | "author" | "subject" | "keywords" | "doccomm" | "creatim")) {
|
||||
@@ -1850,9 +1987,9 @@ impl Reader {
|
||||
self.font_cp_cur = None;
|
||||
}
|
||||
"fcharset" => self.font_cp_cur = wordcraft_doc::encoding::charset_code_page(pv),
|
||||
// A font's explicit code page (`\cpg1251`) wins over its charset.
|
||||
// A font's explicit code page (`\cpg1251`, `\cpg932`) wins over its charset.
|
||||
"cpg" => {
|
||||
if let Some(cp) = u16::try_from(pv).ok().filter(|cp| wordcraft_doc::encoding::is_byte_code_page(*cp)) {
|
||||
if let Some(cp) = u16::try_from(pv).ok().filter(|cp| wordcraft_doc::encoding::is_text_code_page(*cp)) {
|
||||
self.font_cp_cur = Some(cp);
|
||||
}
|
||||
}
|
||||
@@ -2054,7 +2191,7 @@ impl Reader {
|
||||
}
|
||||
"fs" => self.st.fmt.size = if pv > 0 { Some((pv as f32 / 2.0).clamp(1.0, 1638.0)) } else { None },
|
||||
"ansicpg" => {
|
||||
if let Some(cp) = u16::try_from(pv).ok().filter(|cp| wordcraft_doc::encoding::is_byte_code_page(*cp)) {
|
||||
if let Some(cp) = u16::try_from(pv).ok().filter(|cp| wordcraft_doc::encoding::is_text_code_page(*cp)) {
|
||||
self.ansicpg = cp;
|
||||
}
|
||||
}
|
||||
@@ -2555,10 +2692,14 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
match h {
|
||||
Some(v) => {
|
||||
if r.st.dest == Dest::Pict {
|
||||
r.text(&format!("{v:02x}"));
|
||||
// Hex digits, two at a time, without a String per picture byte.
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let h = [HEX[usize::from(v >> 4)], HEX[usize::from(v & 0x0f)]];
|
||||
if let Ok(s) = std::str::from_utf8(&h) {
|
||||
r.text(s);
|
||||
}
|
||||
} else {
|
||||
let c = r.byte_char(v);
|
||||
r.ch(c);
|
||||
r.byte(v);
|
||||
}
|
||||
i += 4;
|
||||
}
|
||||
@@ -2602,22 +2743,27 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
j += 1;
|
||||
}
|
||||
let chunk = bytes.get(i..j).unwrap_or(b"");
|
||||
if r.skip_chars == 0 && chunk.is_ascii() {
|
||||
if r.st.dest == Dest::Pict && r.skip_chars == 0 && chunk.is_ascii() {
|
||||
// Picture hex is not text: it needs no decoder, so neither a pending lead byte
|
||||
// nor the code page's single-byte path applies to it.
|
||||
if let Ok(s) = std::str::from_utf8(chunk) {
|
||||
r.text(s);
|
||||
}
|
||||
} else if r.skip_chars == 0 && chunk.is_ascii() && r.mb_decoder.is_none() {
|
||||
// Plain ASCII with no multibyte run open: append it as it is.
|
||||
if let Ok(s) = std::str::from_utf8(chunk) {
|
||||
r.text(s);
|
||||
}
|
||||
} else {
|
||||
for b in chunk {
|
||||
let c = r.byte_char(*b);
|
||||
r.ch(c);
|
||||
}
|
||||
r.run(chunk);
|
||||
}
|
||||
i = j;
|
||||
first_in_group = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A header or footer left open by a truncated file ends here.
|
||||
// A header or footer left open by a truncated file ends here, and so does a multibyte run.
|
||||
r.finish_multibyte();
|
||||
r.end_header_footer();
|
||||
if r.para_has || !r.para.inlines.is_empty() {
|
||||
r.end_para(false);
|
||||
@@ -3024,6 +3170,126 @@ mod tests {
|
||||
// A code page number out of range is ignored, not truncated to another one.
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg65973 caf\'e9\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:café"]);
|
||||
// The OEM charset (255) is DOS code page 437, like 254.
|
||||
let f = parse(br"{\rtf1\ansi\deff0{\fonttbl{\f0\fcharset255 Courier New;}}\f0 caf\'82\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:café"]);
|
||||
}
|
||||
|
||||
/// #1653: the East Asian code pages (932 Shift-JIS, 936 GBK, 949 UHC, 950 Big5) decode their
|
||||
/// bytes as a run, so a lead byte and its trail byte decode together, escaped or raw.
|
||||
#[test]
|
||||
fn east_asian_multibyte_code_pages() {
|
||||
// Japanese `\ansicpg932` (CP932: 日 = 93 fa, 本 = 96 7b, 語 = 8c ea).
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg932 \'93\'fa\'96\'7b\'8c\'ea\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:日本語"]);
|
||||
// Raw 8-bit text (not `\'hh`), and raw bytes mixed with one escaped byte: RTF can't carry a
|
||||
// raw `{`, `}` or `\`, so 本's trail byte 7b is written `\'7b` here.
|
||||
let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x8a\xbf\x8e\x9a\\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:漢字"]);
|
||||
let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93\xfa\x96\\'7b\x8c\xea\\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:日本語"]);
|
||||
// A font names the code page with `\fcharset128` or with `\cpg932`, and `\f0` selects it.
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\fcharset128 MS Mincho;}}\f0 \'93\'fa\'96\'7b\'8c\'ea\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:日本語"]);
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\cpg932 MS Mincho;}}\f0 \'93\'fa\'96\'7b\'8c\'ea\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:日本語"]);
|
||||
// Raw 8-bit bytes in the font table (the font's name) decode in the document's code page.
|
||||
let d = import(b"{\\rtf1\\ansi\\ansicpg932\\deff0{\\fonttbl{\\f0\\fcharset128 \x96\xbe\x92\xa9;}}\\f0 x\\par}").unwrap();
|
||||
assert_eq!(d.styles.default_chr.font.as_deref(), Some("明朝"));
|
||||
// Korean `\ansicpg949` (UHC: 한국어 = c7 d1 b1 b9 be ee).
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg949 \'c7\'d1\'b1\'b9\'be\'ee\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:한국어"]);
|
||||
// Simplified Chinese `\ansicpg936` (GBK: 中文 = d6 d0 ce c4).
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg936 \'d6\'d0\'ce\'c4\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:中文"]);
|
||||
// Traditional Chinese `\ansicpg950` (Big5: 中文 = a4 a4 a4 e5).
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg950 \'a4\'a4\'a4\'e5\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:中文"]);
|
||||
// `\uN?` escapes still decode alongside multibyte text (`\u26412?` is 本, its `?` fallback
|
||||
// skipped by `\uc1`).
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg932 \'93\'fa\u26412?\'8c\'ea x\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:日本語 x"]);
|
||||
// An invalid sequence decodes as U+FFFD, as Word writes it, and doesn't lose the rest.
|
||||
let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93\\par after\\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:\u{FFFD}", "Normal:after"]);
|
||||
let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93\xfa\x93 x\x96\x8c\\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:日\u{FFFD} x膜"]);
|
||||
}
|
||||
|
||||
/// The decoder never sniffs a BOM: the code page is named by `\ansicpg`, `\cpg` or `\fcharset`,
|
||||
/// so a run that starts `EF BB BF` is a character pair and a lead byte, not a UTF-8 BOM (which
|
||||
/// would switch the rest of the document to UTF-8 and garble it).
|
||||
#[test]
|
||||
fn a_bom_like_byte_triple_is_text_in_a_multibyte_code_page() {
|
||||
// GBK EF BB = 锘, then BF B5 = 康; UHC EF BB = 癤, then BF C0 = 오; Big5 EF BB = 嚜, then
|
||||
// BF 40 = 濃.
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg936 \'ef\'bb\'bf\'b5\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:锘康"]);
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg949 \'ef\'bb\'bf\'c0\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:癤오"]);
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg950 \'ef\'bb\'bf\'40\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:嚜濃"]);
|
||||
}
|
||||
|
||||
/// A multibyte run longer than any fixed buffer keeps every character: the streaming decoder
|
||||
/// holds a lead byte and its trail byte together across decode steps instead of cutting the
|
||||
/// character at a buffer size (the review's finding on the byte-capped flush).
|
||||
#[test]
|
||||
fn a_long_multibyte_run_never_splits_a_character() {
|
||||
let mut bytes = b"{\\rtf1\\ansi\\ansicpg932 ".to_vec();
|
||||
bytes.push(b'A');
|
||||
// 2048 × あ (CP932 82 a0) is 4096 bytes after the one ASCII byte: a fixed 4096-byte buffer
|
||||
// would flush with the last あ's lead byte in one step and its trail byte in the next.
|
||||
for _ in 0..2048 {
|
||||
bytes.extend_from_slice(&[0x82, 0xA0]);
|
||||
}
|
||||
bytes.extend_from_slice(b"\\par}");
|
||||
let f = parse(&bytes).unwrap();
|
||||
let expected: String = std::iter::once('A').chain((0..2048).map(|_| 'あ')).collect();
|
||||
assert_eq!(texts(&f), [format!("Normal:{expected}")]);
|
||||
}
|
||||
|
||||
/// A lead byte with no trail byte at the end of the document becomes U+FFFD, as Word writes a
|
||||
/// malformed sequence — the streaming decoder is finalised, not left holding it.
|
||||
#[test]
|
||||
fn an_incomplete_lead_byte_at_the_end_is_replacement() {
|
||||
let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:\u{FFFD}"]);
|
||||
}
|
||||
|
||||
/// #1653: a code page switch (`\f`, `\fcharset`, `\cpg`, `\ansicpg`), a group end and the end
|
||||
/// of the document decode the multibyte bytes read so far, before the new code page applies.
|
||||
#[test]
|
||||
fn multibyte_code_page_switches_flush_pending_bytes() {
|
||||
// Japanese (932) → ASCII → Cyrillic (`\fcharset204` with `\cpg1251`): each run decodes in
|
||||
// its own code page, the last bytes before the switch included.
|
||||
let f = parse(
|
||||
br"{\rtf1\ansi\ansicpg932\deff0{\fonttbl{\f0 MS Mincho;}{\f1\fcharset204\cpg1251 Arial CYR;}}\f0 \'93\'fa\'96\'7b\'8c\'ea mixed \f1 \'cf\'f0\'e8\'e2\'e5\'f2\par}",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:日本語 mixed Привет"]);
|
||||
// A group end flushes: the group's 932 bytes decode before the outer 1252 text.
|
||||
let f =
|
||||
parse(br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0 Arial;}{\f1\fcharset128 MS Mincho;}}\f0 {\f1 \'93\'fa\'96\'7b\'8c\'ea}caf\'e9\par}")
|
||||
.unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:日本語café"]);
|
||||
// An `\ansicpg` switch mid-document flushes too.
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg932 \'93\'fa\ansicpg1251 \'cf\'f0\'e8\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:日При"]);
|
||||
// A run left open at the end of the document isn't lost.
|
||||
let f = parse(b"{\\rtf1\\ansi\\ansicpg932 \x93\xfa").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:日"]);
|
||||
}
|
||||
|
||||
/// A Windows-1252 document (the default) reads exactly as before: high bytes one at a time.
|
||||
#[test]
|
||||
fn windows_1252_text_is_unchanged() {
|
||||
let f = parse(b"{\\rtf1\\ansi\\ansicpg1252 caf\xe9 na\xefve \x93quoted\x94 \\'80 5\\'b0\\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:café naïve “quoted” € 5°"]);
|
||||
// No `\ansicpg`: the default is 1252; the byte values of an East Asian code page keep
|
||||
// their 1252 meaning.
|
||||
let f = parse(b"{\\rtf1\\ansi \xe9\\'92\\'93 \\'fa\\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:é’“ ú"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -224,6 +224,49 @@ fn html_round_trip() {
|
||||
assert_eq!(back.core.title, "Sample Title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_ruby_round_trip() {
|
||||
// #1681: a phonetic guide exports as `<ruby>` and opens back as one ruby object, its base
|
||||
// text still in the paragraph.
|
||||
let props = CharProps::default();
|
||||
let mut p = Paragraph::with_text("I read ", props.clone());
|
||||
let n = p.len();
|
||||
p.insert_object(n, model::ruby_object("漢字".into(), "かんじ".into(), 20.0), &props).unwrap();
|
||||
let n = p.len();
|
||||
p.insert_text(n, " daily.", &props).unwrap();
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(p)];
|
||||
|
||||
let html = String::from_utf8(export("html", &d).unwrap().unwrap()).unwrap();
|
||||
assert!(html.contains("<ruby>漢字<rt>かんじ</rt></ruby>"), "{html}");
|
||||
let back = import("html", html.as_bytes()).unwrap().unwrap();
|
||||
assert_eq!(back.plain_text(Default::default()), "I read 漢字 daily.");
|
||||
let ruby = back.body.iter().find_map(|b| match &**b {
|
||||
Block::Para(p) => p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Ruby { base, ruby, .. } => Some((base.as_str(), ruby.as_str())),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(ruby, Some(("漢字", "かんじ")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_ruby_at_the_paragraph_start_keeps_a_leading_space() {
|
||||
// #1681: the ruby's base text goes through the text escaper with the paragraph-start state
|
||||
// taken before its formatting tags, so a leading space becomes ` ` instead of a space
|
||||
// HTML may collapse.
|
||||
let props = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut p = Paragraph::with_text("", CharProps::default());
|
||||
let n = p.len();
|
||||
p.insert_object(n, model::ruby_object(" 漢字".into(), "かんじ".into(), 20.0), &props).unwrap();
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(p)];
|
||||
|
||||
let html = String::from_utf8(export("html", &d).unwrap().unwrap()).unwrap();
|
||||
assert!(html.contains("<strong><ruby> 漢字<rt>かんじ</rt></ruby></strong>"), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_round_trip() {
|
||||
check_round_trip("rtf", true);
|
||||
@@ -528,6 +571,12 @@ fn hostile_rtf_bits() {
|
||||
] {
|
||||
let _ = import("rtf", s).unwrap();
|
||||
}
|
||||
// #1653: a flood of multibyte code-page bytes (leads and trails, raw and escaped) decodes as a
|
||||
// stream and never panics or grows a buffer with the input.
|
||||
let mut rtf = b"{\\rtf1\\ansi\\ansicpg932 ".to_vec();
|
||||
rtf.resize(rtf.len() + 200_000, 0x93);
|
||||
rtf.extend_from_slice(b"\\'fa\\par\\'93");
|
||||
assert!(import("rtf", &rtf).unwrap().is_ok());
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
@@ -613,6 +662,31 @@ fn chart_alt_text_is_an_image_alt_never_body_text() {
|
||||
assert_eq!(para_texts(&d), ["See below"]);
|
||||
}
|
||||
|
||||
/// A picture marked decorative exports with an empty alt text, though it keeps its description,
|
||||
/// so screen readers skip it; the same picture not marked decorative keeps its alt text.
|
||||
#[test]
|
||||
fn decorative_picture_exports_with_empty_alt_text() {
|
||||
use wordcraft_doc::para::{AltText, Float};
|
||||
let doc = |decorative: bool| {
|
||||
let mut d = Document::new();
|
||||
let media = d.add_media(png(), "png");
|
||||
let float = Float { alt: AltText { decorative, ..Default::default() }, ..Default::default() };
|
||||
let pic = InlineObject::Image { media, w: 40.0, h: 20.0, alt: "Blue bar".into(), float, crop: [0.0; 4], ole: None };
|
||||
let mut p = Paragraph::with_text("Logo: ", CharProps::default());
|
||||
let n = p.len();
|
||||
p.insert_object(n, pic, &CharProps::default()).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
d
|
||||
};
|
||||
let text = |d: &Document, ext: &str| String::from_utf8(export(ext, d).unwrap().unwrap()).unwrap();
|
||||
let (plain, decorative) = (doc(false), doc(true));
|
||||
assert!(text(&plain, "html").contains(r#"alt="Blue bar""#), "{}", text(&plain, "html"));
|
||||
assert!(text(&plain, "md").contains(", "{}", text(&plain, "md"));
|
||||
let (html, md) = (text(&decorative, "html"), text(&decorative, "md"));
|
||||
assert!(html.contains(r#"alt="""#) && !html.contains("Blue bar"), "{html}");
|
||||
assert!(md.contains(" && !md.contains("Blue bar"), "{md}");
|
||||
}
|
||||
|
||||
/// Hostile Markdown and HTML that rescanned the rest of the text from every `[`, `![`, `<` or `&`
|
||||
/// took quadratic time: a few hundred KB froze the importer for tens of seconds.
|
||||
#[test]
|
||||
|
||||
@@ -162,6 +162,9 @@ pub enum Draw {
|
||||
alpha: f32,
|
||||
/// The picture's alt text (empty for a floating or diagram picture, which tag themselves).
|
||||
alt: String,
|
||||
/// Marked decorative (Alt Text › Mark as decorative): an artifact in tagged PDF, which
|
||||
/// screen readers skip. Always `false` for a floating or diagram picture, as `alt`.
|
||||
decorative: bool,
|
||||
},
|
||||
Shape {
|
||||
rect: Rect,
|
||||
@@ -181,8 +184,9 @@ pub enum Draw {
|
||||
Ink { pts: Vec<(f32, f32)>, color: Rgb, width: f32, alpha: f32 },
|
||||
/// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked.
|
||||
Path { segs: Vec<PathSeg>, fill: Option<Paint>, stroke: Option<Rgb>, stroke_width: f32, dash: Vec<f32> },
|
||||
/// An inline chart or diagram's draws, as one figure with its alt text (for tagged PDF).
|
||||
Figure { alt: String, kind: GraphicKind, draws: Vec<Draw> },
|
||||
/// An inline chart, diagram or described shape's draws, as one figure with its alt text (for
|
||||
/// tagged PDF). `kind` is the graphic's, `None` for a shape (see [`crate::shape_figure_alt`]).
|
||||
Figure { alt: String, kind: Option<GraphicKind>, draws: Vec<Draw> },
|
||||
/// A formatting mark (¶ · → ↵) in the UI's mark colour, or in `color` (a tracked
|
||||
/// paragraph mark in its reviser's colour).
|
||||
Mark { x: f32, baseline: f32, size: f32, ch: char, color: Option<Rgb> },
|
||||
@@ -742,9 +746,12 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer
|
||||
match it {
|
||||
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha, text_bg: false }),
|
||||
Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)),
|
||||
Placed::Image { rect, media, crop, spin, .. } => {
|
||||
spun(*spin, *rect, vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha, alt: String::new() }], out)
|
||||
}
|
||||
Placed::Image { rect, media, crop, spin, .. } => spun(
|
||||
*spin,
|
||||
*rect,
|
||||
vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha, alt: String::new(), decorative: false }],
|
||||
out,
|
||||
),
|
||||
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, freeform, spin, extra, .. } => {
|
||||
let mut items = Vec::new();
|
||||
let fx = effects_in(*effects, spin.within(outer));
|
||||
@@ -1228,7 +1235,7 @@ fn lines(
|
||||
Some(InlineObject::Graphic { graphic, alt, .. }) => {
|
||||
let mut draws = Vec::new();
|
||||
spun(spin, rect, graphic_draws(doc, graphic, rect, alpha), &mut draws);
|
||||
out.push(Draw::Figure { alt: alt.clone(), kind: graphic.kind, draws })
|
||||
out.push(Draw::Figure { alt: alt.clone(), kind: Some(graphic.kind), draws })
|
||||
}
|
||||
Some(InlineObject::Equation { .. }) => {
|
||||
if let Some((_, ml)) = pl.maths.iter().find(|(k, _)| *k == oi) {
|
||||
@@ -1492,7 +1499,7 @@ fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec<Dra
|
||||
}
|
||||
GraphicItem::Text { rect: r, .. } => out.extend(text_glyphs(doc, inside(r), it, alpha, k)),
|
||||
GraphicItem::Image { rect: r, media } => {
|
||||
out.push(Draw::Image { rect: inside(r), media: media.clone(), crop: [0.0; 4], alpha, alt: String::new() })
|
||||
out.push(Draw::Image { rect: inside(r), media: media.clone(), crop: [0.0; 4], alpha, alt: String::new(), decorative: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1809,13 +1816,22 @@ fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &Di
|
||||
let own = o.frame().map(|(_, _, f)| f.spin()).unwrap_or_default();
|
||||
match o {
|
||||
InlineObject::Image { media, crop, alt, .. } => {
|
||||
spun(own, rect, vec![Draw::Image { rect, media: media.clone(), crop: *crop, alpha, alt: alt.clone() }], out)
|
||||
let decorative = o.is_decorative();
|
||||
spun(own, rect, vec![Draw::Image { rect, media: media.clone(), crop: *crop, alpha, alt: alt.clone(), decorative }], out)
|
||||
}
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, extra, .. } => {
|
||||
let effects = effects_in(*effects, own.within(outer));
|
||||
let mut items = Vec::new();
|
||||
shape_draws(rect, *kind, fill.as_ref(), *stroke, *stroke_width, effects, freeform.as_deref(), extra, opts, &mut items);
|
||||
spun(own, rect, items, out)
|
||||
match crate::shape_figure_alt(o) {
|
||||
// A described shape is one figure with its description (tagged PDF).
|
||||
Some(alt) => {
|
||||
let mut draws = Vec::new();
|
||||
spun(own, rect, items, &mut draws);
|
||||
out.push(Draw::Figure { alt: alt.to_string(), kind: None, draws })
|
||||
}
|
||||
None => spun(own, rect, items, out),
|
||||
}
|
||||
}
|
||||
InlineObject::Group { .. } => {
|
||||
let mut members = Vec::new();
|
||||
@@ -1845,6 +1861,7 @@ fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &Di
|
||||
crop: [0.0; 4],
|
||||
alpha,
|
||||
alt: String::new(),
|
||||
decorative: false,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
|
||||
@@ -226,11 +226,12 @@ fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The position in the turned text of `story` nearest to page point (x, y), wherever it is.
|
||||
fn nearest_turned(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> {
|
||||
/// The position in the turned text of `story` nearest to page point (x, y), wherever it is, in a
|
||||
/// paragraph `skip` doesn't leave out.
|
||||
fn nearest_turned(page: &Page, story: StoryRef, x: f32, y: f32, skip: &dyn Fn(&Path) -> bool) -> Option<Pos> {
|
||||
let dist = |r: Rect| band_dist(x, r.x, r.w).hypot(band_dist(y, r.y, r.h));
|
||||
let it = items_of(page, story)
|
||||
.filter(|it| matches!(it, Placed::Lines { story: s, .. } if *s == story))
|
||||
.filter(|it| matches!(it, Placed::Lines { story: s, path, .. } if *s == story && !skip(path)))
|
||||
.filter_map(|it| it.turned_bounds().map(|r| (it, dist(r))))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))?
|
||||
.0;
|
||||
@@ -253,6 +254,23 @@ fn nearest_line_at<'a>(page: &'a Page, y: f32, top_only: bool) -> Option<LineHit
|
||||
.min_by(|a, b| band_dist(y, a.top, a.bottom - a.top).total_cmp(&band_dist(y, b.top, b.bottom - b.top)).then(a.left.total_cmp(&b.left)))
|
||||
}
|
||||
|
||||
/// Each floating body table on `page` with the area its cells cover there.
|
||||
fn float_table_areas(page: &Page) -> Vec<(&Path, Rect)> {
|
||||
page.float_tables
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
page.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Cell { rect, table, story: StoryRef::Body, .. } if table == t => Some(*rect),
|
||||
_ => None,
|
||||
})
|
||||
.reduce(|a, b| a.union(&b))
|
||||
.map(|r| (t, r))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How far `v` is outside the band `top..top + h`.
|
||||
fn band_dist(v: f32, top: f32, h: f32) -> f32 {
|
||||
if v < top {
|
||||
@@ -271,12 +289,17 @@ impl DocLayout {
|
||||
if let Some(pos) = turned_hit(p, story, x, y) {
|
||||
return Some(pos);
|
||||
}
|
||||
// Like Word, a click outside a floating table doesn't land in its cells while there's
|
||||
// other text on the page: below one, it goes to the text beside or after it (#2038).
|
||||
let floats = if story == StoryRef::Body { float_table_areas(p) } else { Vec::new() };
|
||||
let away = |path: &Path| floats.iter().any(|(t, r)| path.0.len() > t.0.len() && path.0.starts_with(&t.0) && !r.contains(Point::new(x, y)));
|
||||
let lines = page_lines(p, story);
|
||||
if lines.is_empty() {
|
||||
// A page of turned text (a vertical section): the nearest turned line.
|
||||
return nearest_turned(p, story, x, y);
|
||||
return nearest_turned(p, story, x, y, &away).or_else(|| nearest_turned(p, story, x, y, &|_: &Path| false));
|
||||
}
|
||||
let best = lines.iter().min_by(|a, b| score(a, x, y).total_cmp(&score(b, x, y)))?;
|
||||
let nearest = |a: &&LineHit, b: &&LineHit| score(a, x, y).total_cmp(&score(b, x, y));
|
||||
let best = lines.iter().filter(|l| !away(l.path)).min_by(nearest).or_else(|| lines.iter().min_by(nearest))?;
|
||||
let off = best.para.off_at_x(best.li, x - best.x);
|
||||
Some(Pos { story: best.story, path: best.path.clone(), off })
|
||||
}
|
||||
|
||||
@@ -279,6 +279,9 @@ pub enum Placed {
|
||||
/// The floating object it draws (a Drawing Canvas's members: the canvas's), for leaving
|
||||
/// exactly one object out of a page (see [`crate::display::DisplayOptions::hide_object`]).
|
||||
object: Option<wordcraft_doc::Pos>,
|
||||
/// The description it is tagged with as a figure in tagged PDF (see
|
||||
/// [`shape_figure_alt`]); empty for a shape that is an artifact.
|
||||
alt: String,
|
||||
},
|
||||
/// A floating chart or diagram, drawn from its items inside `rect`. The object is the U+FFFC at
|
||||
/// byte `off` of paragraph `path` (its alt text).
|
||||
@@ -484,6 +487,9 @@ pub struct Page {
|
||||
/// Width of the markup area on the right of `w` that holds comment balloons (Print Markup,
|
||||
/// [`LayoutOptions::comment_balloons`]); 0 without one. The document page is `w - markup` wide.
|
||||
pub markup: f32,
|
||||
/// The top-level body tables placed on this page as floating tables (`w:tblpPr`): a click
|
||||
/// outside one doesn't land in its cells ([`DocLayout::hit`]).
|
||||
pub float_tables: Vec<Path>,
|
||||
}
|
||||
|
||||
/// The whole layout.
|
||||
@@ -1183,6 +1189,11 @@ fn layout_box(
|
||||
let mut excl: Vec<WrapArea> = Vec::new();
|
||||
// Areas of the box's floating tables, which other floating tables may have to avoid.
|
||||
let mut float_tables: Vec<Rect> = Vec::new();
|
||||
// Deepest a floating picture or shape that takes part in the text flow reaches, so the box
|
||||
// (a table cell especially) grows to hold it instead of letting it hang past the box's own
|
||||
// bottom edge (#3108). Behind-text and in-front-of-text objects don't: a page-anchored logo or
|
||||
// watermark behind a cell's text must not stretch its row (see `align_h` in table.rs).
|
||||
let mut float_objects_bottom = 0.0f32;
|
||||
let mut table_areas = Vec::new();
|
||||
let mut page_table_wraps = Vec::new();
|
||||
let mut y = 0.0f32;
|
||||
@@ -1287,6 +1298,13 @@ fn layout_box(
|
||||
if !p.objects.is_empty() {
|
||||
let at = ObjFrame { page: frame, col: (0.0, width), para_y: y, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
|
||||
let (back, front) = place_objects(ctx, story, &path, p, &pl, (0, pl.lines.len()), (0.0, y + lead), &at, &floats, depth);
|
||||
for placed in back.iter().chain(front.iter()) {
|
||||
if let Placed::Object { rect, spin, wrap, .. } = placed
|
||||
&& !matches!(wrap, Wrap::Inline | Wrap::BehindText | Wrap::InFrontOfText)
|
||||
{
|
||||
float_objects_bottom = float_objects_bottom.max(spin.bounds(*rect).bottom());
|
||||
}
|
||||
}
|
||||
let at = behind.min(items.len());
|
||||
behind += back.len();
|
||||
items.splice(at..at, back);
|
||||
@@ -1347,7 +1365,7 @@ fn layout_box(
|
||||
}
|
||||
}
|
||||
}
|
||||
let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(0.0, f32::max);
|
||||
let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(float_objects_bottom, f32::max);
|
||||
ctx.hide_deleted = outer_hide;
|
||||
BoxLayout { items, height: y.max(0.0), floats_bottom, last_top: last_top.max(0.0), table_areas, page_table_wraps }
|
||||
}
|
||||
@@ -3136,6 +3154,7 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR
|
||||
spin,
|
||||
extra: extra.clone(),
|
||||
object: Some(object),
|
||||
alt: shape_figure_alt(o).unwrap_or_default().to_string(),
|
||||
}]
|
||||
}
|
||||
InlineObject::Graphic { graphic, .. } => {
|
||||
@@ -3164,6 +3183,7 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR
|
||||
spin,
|
||||
extra: Default::default(),
|
||||
object: Some(wordcraft_doc::Pos { story, path: Path(path.to_vec()), off }),
|
||||
alt: String::new(),
|
||||
},
|
||||
Placed::Clip { rect, spin, items: members },
|
||||
]
|
||||
@@ -3175,6 +3195,16 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR
|
||||
}
|
||||
}
|
||||
|
||||
/// The description a shape is tagged with as a figure in tagged PDF: its own, when it has one,
|
||||
/// isn't marked decorative and shows no text. A shape without one is an artifact screen readers
|
||||
/// skip, and a text box's text is read as text, so its description isn't used.
|
||||
pub fn shape_figure_alt(o: &InlineObject) -> Option<&str> {
|
||||
match o {
|
||||
InlineObject::Shape { story: None, float, .. } if !float.alt.decorative && !float.alt.text.trim().is_empty() => Some(&float.alt.text),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's members, each with its rectangle when the group is at `rect`.
|
||||
/// `rect` moved (not turned) to where `spin` about `centre` takes its centre: a group member's
|
||||
/// frame inside its turned group.
|
||||
@@ -4434,6 +4464,7 @@ fn place_floating_table(ctx: &mut Ctx, pb: &mut PageBuilder, tl: &table::TableLa
|
||||
}
|
||||
y += row.height;
|
||||
}
|
||||
pg.float_tables.push(Path::top(block));
|
||||
}
|
||||
let area = r.inset(-dl, -dt, -dr, -db);
|
||||
pb.excl.push((area, false, None, WrapText::BothSides));
|
||||
|
||||
@@ -177,6 +177,7 @@ pub(crate) fn symbol_items(pl: &Arc<ParaLayout>, x: f32, y: f32, level: u8, chil
|
||||
extra: Default::default(),
|
||||
// A heading's symbol, not an object of its own: never left out of a page.
|
||||
object: None,
|
||||
alt: String::new(),
|
||||
};
|
||||
let white = Border { style: BorderStyle::Single, width: 1.1, color: Some(Rgb(0xFF, 0xFF, 0xFF)), space: 0.0 };
|
||||
if level >= BODY {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use wordcraft_doc::numbering::Counters;
|
||||
use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge};
|
||||
use wordcraft_doc::styles::{PartSet, TablePart, TableStyleProps};
|
||||
use wordcraft_doc::{Block, Blocks, Paragraph, StoryRef, Table};
|
||||
use wordcraft_doc::{Block, Blocks, Paragraph, Row, StoryRef, Table};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
use crate::para::{CellText, ClKind, ParaLayout};
|
||||
@@ -181,7 +181,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f
|
||||
let mut spanned: Vec<(usize, usize, f32, f32)> = Vec::new();
|
||||
let mut budget = MEASURE_BUDGET;
|
||||
'rows: for (ri, row) in t.rows.iter().enumerate() {
|
||||
let mut g = 0usize;
|
||||
let mut g = row.first_grid();
|
||||
for cell in &row.cells {
|
||||
let span = cell.span();
|
||||
let margins = cell.props.resolved_margins(margins_def);
|
||||
@@ -240,6 +240,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f
|
||||
}
|
||||
g += span;
|
||||
}
|
||||
spanned.extend(row_gaps(row, g).map(|(g, span, w)| (g, span, w, w)));
|
||||
}
|
||||
for (g, span, min, max) in spanned {
|
||||
let Some(cs) = cols.get_mut(g..(g + span).min(ncols)) else { continue };
|
||||
@@ -473,7 +474,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let mut heights: Vec<f32> = Vec::with_capacity(nrows);
|
||||
let mut bottoms: Vec<f32> = Vec::with_capacity(nrows);
|
||||
for (ri, row) in t.rows.iter().enumerate() {
|
||||
let mut g = 0usize;
|
||||
let mut g = row.first_grid();
|
||||
let mut cells = Vec::with_capacity(row.cells.len());
|
||||
let mut rh = 0.0f32;
|
||||
// The tallest cell margins plus border bands in the row.
|
||||
@@ -520,7 +521,9 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let cb = cell.props.borders.unwrap_or_default();
|
||||
// A row's table property exceptions (`w:tblPrEx`) override the table's borders for its cells.
|
||||
let row_borders = row.props.exceptions.as_ref().and_then(|e| e.borders).unwrap_or_default();
|
||||
let (first_g, last_g) = (g == 0, g + span >= ncols);
|
||||
// A row that skips grid columns (`w:gridBefore`/`w:gridAfter`) has its edge at its own
|
||||
// first and last cell.
|
||||
let (first_g, last_g) = (g == row.first_grid(), g + span >= ncols.saturating_sub(row.props.grid_after as usize));
|
||||
let rb = look.map(|l| region_borders(&l.borders, (ri, nrows), (first_g, last_g))).unwrap_or_default();
|
||||
let mut borders = Borders {
|
||||
top: edge(cb.top.or(if ri == 0 { row_borders.top } else { row_borders.between }).or(rb.top), ri == 0, tb.top, tb.between),
|
||||
@@ -671,7 +674,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
.rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut gx = 0;
|
||||
let mut gx = r.first_grid();
|
||||
r.cells
|
||||
.iter()
|
||||
.filter_map(|x| {
|
||||
@@ -1077,8 +1080,9 @@ type Region = PartSet;
|
||||
|
||||
/// The parts of the cell at grid column `g` spanning `span` of `ncols` columns in row `ri`.
|
||||
fn region_of(t: &Table, style: Option<&TableStyleProps>, ri: usize, g: usize, span: usize, ncols: usize) -> Region {
|
||||
let size = style.and_then(|s| s.parts.band_size).unwrap_or(1);
|
||||
PartSet::of_cell(&t.props.look, size, (ri, t.rows.len()), (g, span, ncols))
|
||||
let rows = style.and_then(|s| s.parts.band_size).unwrap_or(0);
|
||||
let cols = style.and_then(|s| s.parts.col_band_size).unwrap_or(0);
|
||||
PartSet::of_cell(&t.props.look, (rows, cols), (ri, t.rows.len()), (g, span, ncols))
|
||||
}
|
||||
|
||||
/// The table style's text formatting in `region`: each part's character formatting and paragraph
|
||||
@@ -1105,7 +1109,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
|
||||
let mut spanning = Vec::new();
|
||||
let mut any = false;
|
||||
for (ri, row) in t.rows.iter().enumerate() {
|
||||
let mut g = 0usize;
|
||||
let mut g = row.first_grid();
|
||||
for cell in &row.cells {
|
||||
let span = cell.span();
|
||||
if cell.props.vmerge != VMerge::Continue {
|
||||
@@ -1121,6 +1125,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
|
||||
}
|
||||
g += span;
|
||||
}
|
||||
spanning.extend(row_gaps(row, g).map(|(g, span, w)| (g, span, (w, w))));
|
||||
}
|
||||
for (g, span, need) in spanning {
|
||||
let Some(cs) = cols.get_mut(g..(g + span).min(t.cols())) else { continue };
|
||||
@@ -1134,6 +1139,17 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
|
||||
any.then_some(cols)
|
||||
}
|
||||
|
||||
/// The grid columns row `row` leaves empty before its first cell and after its last one (which
|
||||
/// ends at grid column `end`) when it gives their width (`w:wBefore`, `w:wAfter`): (first column,
|
||||
/// columns, width in points). Column widths make room for them like for an empty cell.
|
||||
fn row_gaps(row: &Row, end: usize) -> impl Iterator<Item = (usize, usize, f32)> {
|
||||
let p = &row.props;
|
||||
[(0, row.first_grid(), p.w_before), (end, p.grid_after as usize, p.w_after)]
|
||||
.into_iter()
|
||||
.filter(|&(_, n, _)| n > 0)
|
||||
.filter_map(|(g, n, w)| w.filter(|w| w.is_finite() && *w > 0.0).map(|w| (g, n, w.min(1584.0))))
|
||||
}
|
||||
|
||||
/// The narrowest and widest a block list can be laid out.
|
||||
fn blocks_content(ctx: &mut Ctx, blocks: &Blocks, text: Option<&CellText>, depth: usize) -> (f32, f32) {
|
||||
let (mut lo, mut hi) = (0.0f32, 0.0f32);
|
||||
|
||||
@@ -1474,6 +1474,147 @@ fn table_border_corners_close() {
|
||||
assert!((h_left - (v_left - 1.5)).abs() < 0.01, "horizontal starts at {h_left}, vertical centre line at {v_left}");
|
||||
}
|
||||
|
||||
/// A row with `w:gridBefore` starts at the grid column after the skipped ones, not at the first.
|
||||
#[test]
|
||||
fn grid_before_row_starts_after_skipped_columns() {
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
let mut t = Table::new(2, 3, 468.0);
|
||||
t.rows[1].cells.remove(0);
|
||||
t.rows[1].props.grid_before = 1;
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let x_of = |r: usize, c: usize| {
|
||||
l.pages[0].items.iter().find_map(|i| match i {
|
||||
Placed::Cell { rect, row, cell, .. } if *row == r && *cell == c => Some(rect.x),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let (shifted, second) = (x_of(1, 0).unwrap(), x_of(0, 1).unwrap());
|
||||
assert!((shifted - second).abs() < 0.01, "row 1 starts at {shifted}, grid column 1 at {second}");
|
||||
}
|
||||
|
||||
/// The area of cell `c` of row `r` of the table on page `page`.
|
||||
fn cell_rect(l: &DocLayout, page: usize, r: usize, c: usize) -> Option<wordcraft_geom::Rect> {
|
||||
l.pages.get(page)?.items.iter().find_map(|i| match i {
|
||||
Placed::Cell { rect, row, cell, .. } if *row == r && *cell == c => Some(*rect),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rows that skip grid columns at the start and at the end (`w:gridBefore`, `w:gridAfter`, as in
|
||||
/// #1174): the cells sit on their own grid columns, the middle one keeps its width, and a merge
|
||||
/// down the last column matches by the shifted column.
|
||||
#[test]
|
||||
fn grid_before_and_after_rows_keep_their_columns_and_merges() {
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
let grid = [239.0f32, 679.0, 101.0, 3790.0, 2312.0, 1189.0].map(|w| w / 20.0);
|
||||
let mut t = Table::new(3, 4, 468.0);
|
||||
t.props.fixed = true;
|
||||
t.grid = grid.to_vec();
|
||||
for (ri, row) in t.rows.iter_mut().enumerate() {
|
||||
row.cells.truncate(3);
|
||||
row.props.grid_before = 1;
|
||||
row.props.w_before = Some(grid[0]);
|
||||
for (c, span) in row.cells.iter_mut().zip([2, 1, 2]) {
|
||||
c.props.span = span;
|
||||
c.props.width = None;
|
||||
}
|
||||
row.cells[1] = wordcraft_doc::Cell::with_text("Middle cell, wide enough for a few words on a line.");
|
||||
row.cells[2].props.span = 2;
|
||||
row.cells[2].props.vmerge = if ri == 0 { wordcraft_doc::props::VMerge::Restart } else { wordcraft_doc::props::VMerge::Continue };
|
||||
}
|
||||
// A row without the gap, and one skipping the last column instead.
|
||||
t.rows[1].props = Default::default();
|
||||
t.rows[1].cells.insert(0, wordcraft_doc::Cell::empty());
|
||||
t.rows[2].props.grid_before = 0;
|
||||
t.rows[2].props.grid_after = 1;
|
||||
t.rows[2].cells[0].props.span = 3;
|
||||
t.rows[2].cells[2].props.span = 1;
|
||||
let colx = |g: usize| grid[..g].iter().sum::<f32>();
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let rect = |r, c| cell_rect(&l, 0, r, c).unwrap();
|
||||
let x0 = rect(1, 0).x;
|
||||
let middle = rect(0, 1);
|
||||
assert!((rect(0, 0).x - x0 - colx(1)).abs() < 0.01, "row 0 starts at {}", rect(0, 0).x - x0);
|
||||
assert!((middle.x - x0 - colx(3)).abs() < 0.01 && (middle.w - grid[3]).abs() < 0.01, "middle cell {middle:?}");
|
||||
assert!((rect(2, 2).x - x0 - colx(4)).abs() < 0.01 && (rect(2, 2).w - grid[4]).abs() < 0.01, "row 2's last cell {:?}", rect(2, 2));
|
||||
// The merge runs down all three rows.
|
||||
let merged = rect(0, 2);
|
||||
let rows_h = rect(0, 0).h + rect(1, 0).h + rect(2, 0).h;
|
||||
assert!((merged.h - rows_h).abs() < 0.01, "merged cell {} tall, rows {rows_h}", merged.h);
|
||||
}
|
||||
|
||||
/// A row's own first and last cell carry the table's outer side borders when it skips the grid
|
||||
/// columns beyond them.
|
||||
#[test]
|
||||
fn skipped_columns_put_the_side_borders_on_the_rows_cells() {
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
let mut t = Table::new(3, 3, 468.0);
|
||||
t.props.style = None;
|
||||
let thick = Some(Border::single(3.0));
|
||||
t.props.borders = Some(Borders { left: thick, right: thick, ..Borders::default() });
|
||||
t.rows[1].cells.remove(0);
|
||||
t.rows[1].props.grid_before = 1;
|
||||
t.rows[2].cells.pop();
|
||||
t.rows[2].props.grid_after = 1;
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let rect = |r, c| cell_rect(&l, 0, r, c).unwrap();
|
||||
let vertical_at = |x: f32, y: f32| {
|
||||
l.pages[0].items.iter().any(|i| matches!(i, Placed::Rule { x0, y0, x1, y1, .. } if x0 == x1 && (x0 - x).abs() < 0.01 && *y0 <= y && *y1 >= y))
|
||||
};
|
||||
let (r1, r2) = (rect(1, 0), rect(2, 1));
|
||||
assert!(vertical_at(r1.x, r1.y + r1.h / 2.0), "no left border at row 1's first cell {r1:?}");
|
||||
assert!(vertical_at(r2.x + r2.w, r2.y + r2.h / 2.0), "no right border at row 2's last cell {r2:?}");
|
||||
}
|
||||
|
||||
/// AutoFit makes the skipped columns as wide as the row says (`w:wBefore`).
|
||||
#[test]
|
||||
fn autofit_gives_skipped_columns_their_width() {
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
let mut t = Table::new(2, 2, 468.0);
|
||||
t.props.style = None;
|
||||
for row in &mut t.rows {
|
||||
for c in &mut row.cells {
|
||||
*c = wordcraft_doc::Cell::with_text("A");
|
||||
}
|
||||
}
|
||||
t.rows[1].cells.remove(0);
|
||||
t.rows[1].props.grid_before = 1;
|
||||
t.rows[1].props.w_before = Some(150.0);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let gap = cell_rect(&l, 0, 1, 0).unwrap().x - cell_rect(&l, 0, 0, 0).unwrap().x;
|
||||
assert!((gap - 150.0).abs() < 0.01, "skipped column {gap} wide");
|
||||
}
|
||||
|
||||
/// Rows skipping a grid column keep it skipped on every page, under the repeated header row.
|
||||
#[test]
|
||||
fn grid_before_rows_stay_shifted_across_pages() {
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
let mut t = Table::new(80, 3, 468.0);
|
||||
t.rows[0].props.header = true;
|
||||
for row in t.rows.iter_mut().skip(1) {
|
||||
row.cells.remove(0);
|
||||
row.props.grid_before = 1;
|
||||
}
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 2, "pages {}", l.pages.len());
|
||||
let column1 = cell_rect(&l, 0, 0, 1).unwrap().x;
|
||||
for (pi, p) in l.pages.iter().enumerate() {
|
||||
assert!(cell_rect(&l, pi, 0, 1).is_some_and(|r| (r.x - column1).abs() < 0.01), "no header on page {pi}");
|
||||
for i in &p.items {
|
||||
if let Placed::Cell { rect, row, cell: 0, .. } = i
|
||||
&& *row > 0
|
||||
{
|
||||
assert!((rect.x - column1).abs() < 0.01, "row {row} on page {pi} starts at {}", rect.x);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_own_border_overrides_only_that_side() {
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
@@ -3125,6 +3266,37 @@ fn table_style_paragraph_props_beat_document_defaults() {
|
||||
assert!(g < 18.0, "one 12 pt line, single spaced: {g}");
|
||||
}
|
||||
|
||||
/// #3415: row bands shade only where the table style has a band size. Word reads a style without
|
||||
/// `w:tblStyleRowBandSize` as having no bands; our Grid Table 4 sets one, so with a header row its
|
||||
/// first and third body rows shade.
|
||||
#[test]
|
||||
fn row_bands_shade_only_with_a_band_size() {
|
||||
let band = wordcraft_doc::props::Rgb(0xC1, 0xE4, 0xF5);
|
||||
let shaded_rows = |d: &Document| {
|
||||
let l = lay(d);
|
||||
let cells: Vec<(usize, wordcraft_geom::Rect)> =
|
||||
l.pages[0].items.iter().filter_map(|i| if let Placed::Cell { rect, row, .. } = i { Some((*row, *rect)) } else { None }).collect();
|
||||
let mut rows: Vec<usize> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| if let Placed::Fill { rect, color } = i { (*color == band).then_some(*rect) } else { None })
|
||||
.filter_map(|f| cells.iter().find(|(_, c)| c.y <= f.y + f.h / 2.0 && f.y + f.h / 2.0 <= c.bottom()).map(|(r, _)| *r))
|
||||
.collect();
|
||||
rows.dedup();
|
||||
rows
|
||||
};
|
||||
let mut d = styled_table_doc(Some("GridTable4AccentBlue"), 5);
|
||||
assert_eq!(shaded_rows(&d), vec![1, 3], "Grid Table 4 bands one row at a time");
|
||||
if let Some(t) = d.styles.get_mut("GridTable4AccentBlue").and_then(|s| s.table.as_mut()) {
|
||||
t.band_size = None;
|
||||
}
|
||||
assert_eq!(shaded_rows(&d), Vec::<usize>::new(), "no band size, no bands");
|
||||
if let Some(t) = d.styles.get_mut("GridTable4AccentBlue").and_then(|s| s.table.as_mut()) {
|
||||
t.band_size = Some(2);
|
||||
}
|
||||
assert_eq!(shaded_rows(&d), vec![1, 2], "bands of two rows");
|
||||
}
|
||||
|
||||
/// The paragraph's own style (Normal included) and direct formatting beat the table style; the
|
||||
/// table style only fills in what they leave unset.
|
||||
#[test]
|
||||
@@ -4996,6 +5168,74 @@ fn a_floating_table_in_a_cell_takes_no_room_and_the_cell_holds_it() {
|
||||
assert!(below_y > last_y + 10.0, "text below the outer table at {below_y}, the inner table's last row at {last_y}");
|
||||
}
|
||||
|
||||
/// A floating (anchored, non-inline) picture in a cell grows the row to hold it too, the same
|
||||
/// way a floating table does: it must not hang past the row's own bottom edge and overlap
|
||||
/// whatever follows the table (#3108).
|
||||
#[test]
|
||||
fn a_floating_picture_in_a_cell_grows_the_row_to_hold_it() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let mut t = Table::new(1, 1, 468.0);
|
||||
let mut cp = wordcraft_doc::Paragraph::with_text("Caption", Default::default());
|
||||
// Short text alone would need one line (well under 100pt); the floated picture is much
|
||||
// taller than that, so only its own height should set the row's height.
|
||||
let picture = Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Margin, x: 0.0, y: 0.0, ..Default::default() };
|
||||
cp.insert_object(0, rect_shape(200.0, 300.0, picture), &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)];
|
||||
let mut d = Document::from_text("Below the table");
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let [(_, row_y, _)] = para_at(&l, &[0, 0, 0, 0])[..] else { panic!("cell text line missing") };
|
||||
let [(_, below_y, _)] = para_at(&l, &[1])[..] else { panic!("line after the table missing") };
|
||||
assert!(below_y - row_y > 290.0, "text after the table at {below_y}, the row's text at {row_y}: the row didn't grow for the 300pt picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_floating_picture_grows_the_row_by_its_rotated_extent() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let mut t = Table::new(1, 1, 468.0);
|
||||
let mut cp = wordcraft_doc::Paragraph::with_text("Caption", Default::default());
|
||||
// Unrotated this shape's own `rect.bottom()` is only 40pt down. Rotated 90° about its centre
|
||||
// (`Spin::bounds`: width and height swap), its drawn bottom moves to (w+h)/2 = 220pt down —
|
||||
// using the unrotated rect here would undergrow the row to a fraction of that.
|
||||
let picture = Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Margin, x: 0.0, y: 0.0, rot: 90.0, ..Default::default() };
|
||||
cp.insert_object(0, rect_shape(400.0, 40.0, picture), &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)];
|
||||
let mut d = Document::from_text("Below the table");
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let [(_, row_y, _)] = para_at(&l, &[0, 0, 0, 0])[..] else { panic!("cell text line missing") };
|
||||
let [(_, below_y, _)] = para_at(&l, &[1])[..] else { panic!("line after the table missing") };
|
||||
assert!(
|
||||
below_y - row_y > 150.0,
|
||||
"text after the table at {below_y}, the row's text at {row_y}: the row didn't grow for the picture's rotated extent (unrotated bottom is only 40pt, rotated should reach ~220pt)"
|
||||
);
|
||||
}
|
||||
|
||||
/// #3108 review: only floats that take part in the text flow grow the row. A behind-text (or
|
||||
/// in-front-of-text) picture taller than the cell's text leaves the row at the text's height, as
|
||||
/// in Word — a page-anchored logo or watermark behind a cell must not stretch its row.
|
||||
#[test]
|
||||
fn a_behind_or_in_front_of_text_picture_in_a_cell_leaves_the_row_alone() {
|
||||
use wordcraft_doc::para::{Anchor, Float, Wrap};
|
||||
let row_gap = |wrap: Wrap| {
|
||||
let mut t = Table::new(1, 1, 468.0);
|
||||
let mut cp = wordcraft_doc::Paragraph::with_text("Caption", Default::default());
|
||||
let picture = Float { wrap, h_rel: Anchor::Margin, v_rel: Anchor::Margin, x: 0.0, y: 0.0, ..Default::default() };
|
||||
cp.insert_object(0, rect_shape(200.0, 300.0, picture), &Default::default()).unwrap();
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)];
|
||||
let mut d = Document::from_text("Below the table");
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let [(_, row_y, _)] = para_at(&l, &[0, 0, 0, 0])[..] else { panic!("cell text line missing") };
|
||||
let [(_, below_y, _)] = para_at(&l, &[1])[..] else { panic!("line after the table missing") };
|
||||
below_y - row_y
|
||||
};
|
||||
for wrap in [Wrap::BehindText, Wrap::InFrontOfText] {
|
||||
let gap = row_gap(wrap);
|
||||
assert!(gap < 60.0, "{wrap:?}: text after the table is {gap}pt below the cell's text; the 300pt picture grew the row");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_sizes_columns_by_their_content() {
|
||||
let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default()));
|
||||
|
||||
@@ -118,3 +118,52 @@ fn headers_stay_horizontal_and_a_vertical_section_starts_a_page() {
|
||||
assert_eq!(l.caret(&Pos::body(0, 0)).unwrap().width, 0.0);
|
||||
assert!(l.caret(&Pos::body(1, 0)).unwrap().width > 0.0);
|
||||
}
|
||||
|
||||
/// #2038 in a vertical section, where every line is turned: a click outside a floating table
|
||||
/// goes to the text beside or after it, not to a cell of the table nearer the click.
|
||||
#[test]
|
||||
fn a_click_outside_a_floating_table_in_a_vertical_section_skips_its_cells() {
|
||||
use wordcraft_doc::para::Anchor;
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
let mut d = Document::from_text("Text");
|
||||
d.last_section.text_direction = TextDirection::Down;
|
||||
let mut t = wordcraft_doc::Table::new(3, 2, 200.0);
|
||||
t.props.float = Some(TableFloat {
|
||||
h_rel: Anchor::Column,
|
||||
v_rel: Anchor::Paragraph,
|
||||
x: 150.0,
|
||||
dist: [9.0, 0.0, 9.0, 0.0],
|
||||
overlap: true,
|
||||
..Default::default()
|
||||
});
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let page = &l.pages[0];
|
||||
assert_eq!(page.float_tables, [Path::top(0)]);
|
||||
let area =
|
||||
page.items.iter().filter_map(|it| if let Placed::Cell { rect, .. } = it { Some(*rect) } else { None }).reduce(|a, b| a.union(&b)).unwrap();
|
||||
let turned: Vec<(&Path, Rect)> = page
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Body, path, .. } => it.turned_bounds().map(|r| (path, r)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!turned.is_empty() && hit::page_lines(page, StoryRef::Body).is_empty(), "only turned lines on the page");
|
||||
let band = |v: f32, lo: f32, len: f32| (lo - v).max(v - lo - len).max(0.0);
|
||||
let dist = |r: &Rect, x: f32, y: f32| band(x, r.x, r.w).hypot(band(y, r.y, r.h));
|
||||
// Points just outside the table, off every line, whose nearest line is one of the table's.
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let points: Vec<(f32, f32)> = [(area.x - 12.0, cy), (area.right() + 12.0, cy), (cx, area.y - 12.0), (cx, area.bottom() + 12.0)]
|
||||
.into_iter()
|
||||
.filter(|&(x, y)| page.body.contains(Point::new(x, y)) && turned.iter().all(|(_, r)| !r.expand(2.0).contains(Point::new(x, y))))
|
||||
.filter(|&(x, y)| turned.iter().min_by(|a, b| dist(&a.1, x, y).total_cmp(&dist(&b.1, x, y))).is_some_and(|(p, _)| p.0.len() > 1))
|
||||
.collect();
|
||||
assert!(!points.is_empty(), "a click there would land in the table: {area:?}");
|
||||
for (x, y) in points {
|
||||
assert_eq!(l.hit(0, x, y, StoryRef::Body).unwrap().path, Path::top(1), "a click at ({x}, {y})");
|
||||
}
|
||||
// Inside the table a click still lands in a cell.
|
||||
assert!(l.hit(0, cx, cy, StoryRef::Body).unwrap().path.0.len() > 1);
|
||||
}
|
||||
|
||||
+70
-11
@@ -350,8 +350,15 @@ fn visual_order(text: &str, glyphs: &[(u32, f32, f32)], clusters: &[std::ops::Ra
|
||||
_ => groups.push((*x, vec![i])),
|
||||
}
|
||||
}
|
||||
// Stable: clusters at the same x keep their order, as do glyphs sharing their text.
|
||||
groups.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
// Clusters at the same x go in text order, reversed when right to left: some fonts draw
|
||||
// lam-alef as a lam and a zero-width alef at one x, and a right-to-left run whose ranges
|
||||
// don't decrease loses its reading order (#2310). Stable for glyphs sharing their text.
|
||||
let tie = |idx: &[usize]| -> i64 {
|
||||
let Some(r) = idx.first().and_then(|&g| clusters.get(g)) else { return 0 };
|
||||
let start = i64::try_from(r.start).unwrap_or(i64::MAX);
|
||||
if text.get(r.clone()).is_some_and(|t| t.chars().any(wordcraft_fonts::is_rtl)) { -start } else { start }
|
||||
};
|
||||
groups.sort_by(|a, b| a.0.total_cmp(&b.0).then_with(|| tie(&a.1).cmp(&tie(&b.1))));
|
||||
groups
|
||||
.into_iter()
|
||||
.flat_map(|(_, mut idx)| {
|
||||
@@ -379,6 +386,23 @@ fn graphic_alt(alt: &str, kind: GraphicKind) -> &str {
|
||||
if alt.trim().is_empty() { kind.noun() } else { alt }
|
||||
}
|
||||
|
||||
/// A picture, or a rotation holding one at any depth (a rotated group's turned members).
|
||||
fn holds_picture(d: &Draw) -> bool {
|
||||
match d {
|
||||
Draw::Image { .. } => true,
|
||||
Draw::Rotated { items, .. } => items.iter().any(holds_picture),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The alt text of an inline figure: a graphic's (see [`graphic_alt`]) or a described shape's.
|
||||
fn figure_alt(alt: &str, kind: Option<GraphicKind>) -> &str {
|
||||
match kind {
|
||||
Some(k) => graphic_alt(alt, k),
|
||||
None => alt,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the segments of a kurbo path to a krilla path.
|
||||
fn append_path(pb: &mut PathBuilder, path: &kurbo::BezPath) {
|
||||
let f = |v: f64| v as f32;
|
||||
@@ -837,6 +861,33 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A rotated inline picture or group: turned as a whole, with each picture in it tagged on its
|
||||
/// own, so a decorative picture is an artifact while the others stay figures with their alt
|
||||
/// text. A described shape's figure keeps its description; anything else is an artifact.
|
||||
fn rotated_pictures(&mut self, s: &mut Surface, d: &Draw) {
|
||||
match d {
|
||||
Draw::Rotated { cx, cy, spin, items } => {
|
||||
if !ok(*cx) || !ok(*cy) {
|
||||
return;
|
||||
}
|
||||
let [a, b, c, d, e, f] = spin.matrix(*cx, *cy);
|
||||
s.push_transform(&Transform::from_row(a, b, c, d, e, f));
|
||||
for it in items {
|
||||
self.rotated_pictures(s, it);
|
||||
}
|
||||
s.pop();
|
||||
}
|
||||
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
|
||||
Draw::Figure { alt, draws, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| {
|
||||
for d in draws {
|
||||
me.draw(s, d);
|
||||
}
|
||||
}),
|
||||
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
}
|
||||
}
|
||||
|
||||
fn page(&mut self, s: &mut Surface, page: &Page, w: f32, h: f32) {
|
||||
if let Some(c) = self.doc.settings.page_color
|
||||
&& let Some(p) = rect_path(&Rect::new(0.0, 0.0, w, h))
|
||||
@@ -880,14 +931,14 @@ impl Exporter<'_> {
|
||||
for d in &draws {
|
||||
match d {
|
||||
Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
|
||||
// A picture marked decorative: an artifact screen readers skip.
|
||||
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
|
||||
// A rotated inline picture.
|
||||
Draw::Rotated { items, .. } if items.iter().any(|i| matches!(i, Draw::Image { .. })) => {
|
||||
let alt = items.iter().find_map(|i| if let Draw::Image { alt, .. } = i { Some(alt.as_str()) } else { None });
|
||||
self.tagged(s, Role::Figure, alt, |me, s| me.draw(s, d))
|
||||
}
|
||||
// An inline chart or diagram: its text is part of the figure, not the paragraph.
|
||||
Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(graphic_alt(alt, *kind)), |me, s| {
|
||||
// A rotated inline picture, or a rotated group holding pictures: turned as a
|
||||
// whole, each picture tagged on its own (see `rotated_pictures`).
|
||||
Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d),
|
||||
// An inline chart, diagram or described shape: its text is part of the figure, not the paragraph.
|
||||
Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| {
|
||||
for d in inner {
|
||||
me.draw(s, d);
|
||||
}
|
||||
@@ -897,16 +948,24 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
Placed::Image { story, path, off, .. } => {
|
||||
let alt = match self.doc.para(*story, path).and_then(|p| p.object_at(*off)) {
|
||||
let obj = self.doc.para(*story, path).and_then(|p| p.object_at(*off));
|
||||
let role = if obj.is_some_and(InlineObject::is_decorative) { Role::Artifact(ArtifactType::Other) } else { Role::Figure };
|
||||
let alt = match obj {
|
||||
Some(InlineObject::Image { alt, .. }) => alt.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
self.tagged(s, Role::Figure, Some(&alt), |me, s| {
|
||||
self.tagged(s, role, Some(&alt), |me, s| {
|
||||
for d in &draws {
|
||||
me.draw(s, d);
|
||||
}
|
||||
});
|
||||
}
|
||||
// A floating shape with a description: a figure (see `shape_figure_alt`).
|
||||
Placed::Shape { alt, .. } if !alt.is_empty() => self.tagged(s, Role::Figure, Some(alt), |me, s| {
|
||||
for d in &draws {
|
||||
me.draw(s, d);
|
||||
}
|
||||
}),
|
||||
Placed::Graphic { story, path, off, graphic, .. } => {
|
||||
let alt = match self.doc.para(*story, path).and_then(|p| p.object_at(*off)) {
|
||||
Some(InlineObject::Graphic { alt, .. }) => graphic_alt(alt, graphic.kind).to_string(),
|
||||
|
||||
@@ -978,6 +978,72 @@ fn inline_picture_figure_keeps_its_alt_text() {
|
||||
assert!(raw("").contains("/Alt(picture)"), "no alt text keeps the fallback");
|
||||
}
|
||||
|
||||
/// A tagged PDF of one picture in a paragraph, placed by `float`, with alt text "A red square".
|
||||
fn tagged_picture_pdf(float: wordcraft_doc::para::Float) -> String {
|
||||
let mut d = Document::new();
|
||||
let media = d.add_media(red_right_half_emf(), "emf");
|
||||
let mut p = Paragraph::with_text("Figure: ", CharProps::default());
|
||||
let n = p.len();
|
||||
let obj = InlineObject::Image { media, w: 70.0, h: 70.0, alt: "A red square".into(), float, crop: [0.0; 4], ole: None };
|
||||
p.insert_object(n, obj, &CharProps::default()).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned()
|
||||
}
|
||||
|
||||
/// A picture marked decorative is an artifact in tagged PDF, inline, rotated or floating, so
|
||||
/// screen readers skip it; the same picture not marked decorative stays a Figure with its alt text.
|
||||
#[test]
|
||||
fn decorative_pictures_are_artifacts_not_figures() {
|
||||
use wordcraft_doc::para::{AltText, Anchor, Float, Wrap};
|
||||
let decorative = AltText { decorative: true, ..Default::default() };
|
||||
let floating = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 200.0, ..Default::default() };
|
||||
let cases = [
|
||||
("inline", Float::default()),
|
||||
("rotated inline", Float { rot: 30.0, ..Default::default() }),
|
||||
("floating", floating.clone()),
|
||||
("rotated floating", Float { rot: 30.0, ..floating }),
|
||||
];
|
||||
for (name, float) in cases {
|
||||
let plain = tagged_picture_pdf(float.clone());
|
||||
assert!(plain.contains("/Alt(A red square)"), "{name}: a described picture is a Figure with its alt text");
|
||||
let raw = tagged_picture_pdf(Float { alt: decorative.clone(), ..float });
|
||||
assert!(!raw.contains("/Alt("), "{name}: a decorative picture has no Figure and no alt text");
|
||||
assert!(raw.contains("1 0 0 rg"), "{name}: the decorative picture is still drawn");
|
||||
}
|
||||
}
|
||||
|
||||
/// In a rotated group, each picture is tagged on its own: the decorative one is an artifact, the
|
||||
/// other stays a Figure with its alt text.
|
||||
#[test]
|
||||
fn rotated_group_tags_each_picture_on_its_own() {
|
||||
use wordcraft_doc::para::{AltText, Float, GroupChild};
|
||||
let mut d = Document::new();
|
||||
let media = d.add_media(red_right_half_emf(), "emf");
|
||||
let picture =
|
||||
|alt: &str, float: Float| InlineObject::Image { media: media.clone(), w: 70.0, h: 70.0, alt: alt.into(), float, crop: [0.0; 4], ole: None };
|
||||
let decorative = Float { alt: AltText { decorative: true, ..Default::default() }, ..Default::default() };
|
||||
let group = InlineObject::Group {
|
||||
w: 160.0,
|
||||
h: 70.0,
|
||||
float: Float { rot: 30.0, ..Default::default() },
|
||||
ch_w: 160.0,
|
||||
ch_h: 70.0,
|
||||
children: vec![
|
||||
GroupChild { x: 0.0, y: 0.0, obj: picture("A decorative border", decorative) },
|
||||
GroupChild { x: 90.0, y: 0.0, obj: picture("A red square", Float::default()) },
|
||||
],
|
||||
canvas: None,
|
||||
};
|
||||
let mut p = Paragraph::with_text("Group: ", CharProps::default());
|
||||
let n = p.len();
|
||||
p.insert_object(n, group, &CharProps::default()).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let raw = String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned();
|
||||
assert!(raw.contains("/Alt(A red square)"), "the picture not marked decorative is a Figure with its alt text");
|
||||
assert!(!raw.contains("A decorative border"), "the decorative picture has no Figure and no alt text");
|
||||
assert_eq!(raw.matches("/Alt(").count(), 1, "one Figure, for the picture not marked decorative");
|
||||
}
|
||||
|
||||
/// An EMF file of the given records, with a 25 mm frame and 1000 device pixels across (0.04 per 0.01 mm).
|
||||
fn emf_file(records: &[Vec<u8>]) -> Vec<u8> {
|
||||
let le32 = |vals: &[i32]| -> Vec<u8> { vals.iter().flat_map(|v| v.to_le_bytes()).collect() };
|
||||
@@ -1673,6 +1739,21 @@ fn left_to_right_glyphs_keep_their_order() {
|
||||
assert!(left_to_right(&glyphs));
|
||||
}
|
||||
|
||||
/// A lam-alef some fonts draw as a lam and a zero-width alef at the same x goes in reverse
|
||||
/// text order, so the run's ranges still decrease and viewers read لا, not ال (#2310).
|
||||
/// Left-to-right clusters at one x keep their text order.
|
||||
#[test]
|
||||
fn clusters_at_the_same_x_go_in_reading_order() {
|
||||
let text = "سلا";
|
||||
let glyphs = [(3, 20.0, 10.0), (1, 10.0, 10.0), (2, 10.0, 0.0)];
|
||||
let ranges = [0..2, 2..4, 4..6];
|
||||
assert_eq!(visual_order(text, &glyphs, &ranges, &ranges), [2, 1, 0]);
|
||||
let text = "ab";
|
||||
let glyphs = [(1, 10.0, 0.0), (2, 10.0, 5.0)];
|
||||
let ranges = [0..1, 1..2];
|
||||
assert_eq!(visual_order(text, &glyphs, &ranges, &ranges), [0, 1]);
|
||||
}
|
||||
|
||||
/// Right-to-left glyphs (reading order, drawn right to left) are written left to right a
|
||||
/// cluster at a time. A letter's vowel mark takes its own character and goes before the letter,
|
||||
/// so a viewer reversing the word reads letter then mark; a lam-alef drawn as two glyphs keeps
|
||||
@@ -1981,3 +2062,59 @@ fn shape_gradients_export_as_shadings() {
|
||||
assert!(raw.contains("/ShadingType 3"), "radial shading");
|
||||
assert!(raw.contains("/ca 0.3"), "30% opaque fill");
|
||||
}
|
||||
|
||||
/// A tagged PDF of a paragraph with one blue rectangle placed by `float`: a text box showing
|
||||
/// "Inside the box" when `text_box`.
|
||||
fn tagged_shape_pdf(float: wordcraft_doc::para::Float, text_box: bool) -> String {
|
||||
use wordcraft_doc::para::ShapeKind;
|
||||
let mut d = Document::from_text("Body");
|
||||
let story = text_box
|
||||
.then(|| d.add_part(wordcraft_doc::PartKind::TextBox, vec![para_block(Paragraph::with_text("Inside the box", CharProps::default()))]));
|
||||
let kind = if text_box { ShapeKind::TextBox } else { ShapeKind::Rectangle };
|
||||
let shape = InlineObject::Shape {
|
||||
kind,
|
||||
w: 120.0,
|
||||
h: 60.0,
|
||||
fill: Some(wordcraft_doc::fill::Fill::solid(wordcraft_doc::props::Rgb(0, 0, 0xFF))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float,
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
d.insert_object(&wordcraft_doc::Pos::body(0, 4), shape, &Default::default()).unwrap();
|
||||
String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned()
|
||||
}
|
||||
|
||||
/// A shape with a description is a Figure with it in tagged PDF, inline, rotated or floating; one
|
||||
/// without, or marked decorative, stays an artifact screen readers skip, and so does a text box's
|
||||
/// frame: its text is read as text, not replaced by its description.
|
||||
#[test]
|
||||
fn described_shapes_are_figures_with_their_description() {
|
||||
use wordcraft_doc::para::{AltText, Anchor, Float, Wrap};
|
||||
let described = AltText { text: "Arrow pointing to the exit".into(), ..Default::default() };
|
||||
let floating = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 200.0, ..Default::default() };
|
||||
let cases = [
|
||||
("inline", Float::default()),
|
||||
("rotated inline", Float { rot: 30.0, ..Default::default() }),
|
||||
("floating", floating.clone()),
|
||||
("rotated floating", Float { rot: 30.0, ..floating }),
|
||||
];
|
||||
for (name, float) in cases {
|
||||
let raw = tagged_shape_pdf(Float { alt: described.clone(), ..float.clone() }, false);
|
||||
assert!(raw.contains("/Alt(Arrow pointing to the exit)"), "{name}: a described shape is a Figure with its description");
|
||||
assert_eq!(raw.matches("/Alt(").count(), 1, "{name}: the described shape is one Figure");
|
||||
assert!(raw.contains("0 0 1 rg"), "{name}: the shape is drawn");
|
||||
assert!(!tagged_shape_pdf(float.clone(), false).contains("/Alt("), "{name}: an undescribed shape stays an artifact");
|
||||
let decorative = AltText { decorative: true, ..described.clone() };
|
||||
assert!(
|
||||
!tagged_shape_pdf(Float { alt: decorative, ..float.clone() }, false).contains("/Alt("),
|
||||
"{name}: a decorative shape stays an artifact"
|
||||
);
|
||||
let text_box = tagged_shape_pdf(Float { alt: described.clone(), ..float }, true);
|
||||
assert!(!text_box.contains("/Alt("), "{name}: a text box is not a Figure");
|
||||
assert_eq!(text_box.matches("/S/P").count(), 2, "{name}: the text box's text is read as a paragraph");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ pub fn table_style_preview(ui: &mut Ui, style: Option<&TableStyleProps>, look: T
|
||||
let (cw, rh) = (table.width() / COLS as f32, table.height() / ROWS as f32);
|
||||
let rgb = |c: Rgb| egui::Color32::from_rgb(c.0, c.1, c.2);
|
||||
let tb = st.parts.borders.unwrap_or_default();
|
||||
let band = st.parts.band_size.unwrap_or(1);
|
||||
let band = (st.parts.band_size.unwrap_or(0), st.parts.col_band_size.unwrap_or(0));
|
||||
for (ri, row) in TEXT.iter().enumerate() {
|
||||
for (ci, txt) in row.iter().enumerate() {
|
||||
// Sample words in the interface language; numbers as they are.
|
||||
|
||||
@@ -2610,6 +2610,7 @@
|
||||
Submitting print job… Изпращане на задание за печат…
|
||||
Print job submitted. Заданието за печат е изпратено.
|
||||
Print job submitted: {receipt} Заданието за печат е изпратено: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Заданието за печат е изпратено: подадени {count} листа към спулера
|
||||
Print failed: the printing worker stopped. Печатът е неуспешен: процесът на печат спря.
|
||||
Print settings Настройки за печат
|
||||
Submit print job Изпращане на задание за печат
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2613,6 +2613,7 @@ preview Title Ukázka
|
||||
Submitting print job… Odesílání tiskové úlohy…
|
||||
Print job submitted. Tisková úloha odeslána.
|
||||
Print job submitted: {receipt} Tisková úloha odeslána: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Tisková úloha odeslána: do tiskové fronty předáno listů: {count}
|
||||
Print failed: the printing worker stopped. Tisk se nezdařil: proces tisku se zastavil.
|
||||
Print settings Nastavení tisku
|
||||
Submit print job Odeslat tiskovou úlohu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2227,6 +2227,7 @@
|
||||
Submitting print job… Druckauftrag wird gesendet…
|
||||
Print job submitted. Druckauftrag gesendet.
|
||||
Print job submitted: {receipt} Druckauftrag gesendet: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Druckauftrag gesendet: {count} Blatt an den Spooler übergeben
|
||||
Print failed: the printing worker stopped. Drucken fehlgeschlagen: Der Druckvorgang wurde angehalten.
|
||||
Print settings Druckeinstellungen
|
||||
Submit print job Druckauftrag senden
|
||||
@@ -2350,3 +2351,7 @@
|
||||
Deep Tief
|
||||
Lit from Bottom Von unten beleuchtet
|
||||
Center Glow Leuchten aus der Mitte
|
||||
# File › Home greeting
|
||||
Good morning Guten Morgen
|
||||
Good afternoon Guten Tag
|
||||
Good evening Guten Abend
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2594,6 +2594,7 @@ preview Title Título
|
||||
Submitting print job… Enviando trabajo de impresión…
|
||||
Print job submitted. Trabajo de impresión enviado.
|
||||
Print job submitted: {receipt} Trabajo de impresión enviado: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Trabajo de impresión enviado: {count} hoja(s) en cola de impresión
|
||||
Print failed: the printing worker stopped. La impresión falló: el proceso de impresión se detuvo.
|
||||
Print settings Configuración de impresión
|
||||
Submit print job Enviar trabajo de impresión
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2262,6 +2262,7 @@
|
||||
Submitting print job… Prinditöö saatmine…
|
||||
Print job submitted. Prinditöö saadetud.
|
||||
Print job submitted: {receipt} Prinditöö saadetud: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Printimistöö saadetud: prindijärjekorda lisati lehti: {count}
|
||||
Print failed: the printing worker stopped. Printimine nurjus: printimisprotsess peatus.
|
||||
Print settings Printimisseaded
|
||||
Submit print job Saada prinditöö
|
||||
@@ -2385,3 +2386,7 @@
|
||||
Deep Sügav
|
||||
Lit from Bottom Alt valgustatud
|
||||
Center Glow Keskne kuma
|
||||
# File › Home greeting
|
||||
Good morning Tere hommikust
|
||||
Good afternoon Tere päevast
|
||||
Good evening Tere õhtust
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 850 and column 23.
|
@@ -2235,6 +2235,7 @@
|
||||
Submitting print job… Envoi du travail d'impression…
|
||||
Print job submitted. Travail d'impression envoyé.
|
||||
Print job submitted: {receipt} Travail d'impression envoyé : {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Travail d’impression envoyé : {count} feuille(s) mise(s) en file d’attente
|
||||
Print failed: the printing worker stopped. L'impression a échoué : le processus d'impression s'est arrêté.
|
||||
Print settings Paramètres d'impression
|
||||
Submit print job Envoyer le travail d'impression
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -1681,6 +1681,7 @@ template Letter Pismo
|
||||
Print failed: {error} Ispis nije uspio: {error}
|
||||
Print job submitted. Zadatak ispisa je poslan.
|
||||
Print job submitted: {receipt} Zadatak ispisa je poslan: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Zadatak ispisa je poslan: u red za ispis poslano listova: {count}
|
||||
Print the page color Ispiši boju stranice
|
||||
Print to PDF Ispiši u PDF
|
||||
Print\nLayout Izgled\nispisa
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2617,6 +2617,7 @@
|
||||
Submitting print job… Nyomtatási feladat küldése…
|
||||
Print job submitted. Nyomtatási feladat elküldve.
|
||||
Print job submitted: {receipt} Nyomtatási feladat elküldve: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) A nyomtatási feladat elküldve: {count} lap a nyomtatási sorba került
|
||||
Print failed: the printing worker stopped. A nyomtatás sikertelen: a nyomtatási folyamat leállt.
|
||||
Print settings Nyomtatási beállítások
|
||||
Submit print job Nyomtatási feladat küldése
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2604,6 +2604,7 @@ preview Title Esempio
|
||||
Submitting print job… Invio del lavoro di stampa in corso…
|
||||
Print job submitted. Lavoro di stampa inviato.
|
||||
Print job submitted: {receipt} Lavoro di stampa inviato: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Lavoro di stampa inviato: {count} foglio/i messo/i in coda
|
||||
Print failed: the printing worker stopped. Stampa non riuscita: il processo di stampa si è interrotto.
|
||||
Print settings Impostazioni di stampa
|
||||
Submit print job Invia lavoro di stampa
|
||||
@@ -2771,3 +2772,19 @@ preview Title Esempio
|
||||
Deep Profondo
|
||||
Lit from Bottom Illuminato dal basso
|
||||
Center Glow Bagliore centrale
|
||||
# Paper sizes, Go To, bookmarks and print labels the Croatian, Swedish and Vietnamese catalogs added
|
||||
B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33"
|
||||
B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12"
|
||||
Executive 7.25" × 10.50" Executive 7,25" × 10,50"
|
||||
Legal 8.50" × 14.00" Legal 8,50" × 14,00"
|
||||
Letter 8.50" × 11.00" Letter 8,50" × 11,00"
|
||||
Statement 5.50" × 8.50" Statement 5,50" × 8,50"
|
||||
Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00"
|
||||
Envelope #10 4.12" × 9.50" Busta n. 10 4,12" × 9,50"
|
||||
Envelope DL 4.33" × 8.66" Busta DL 4,33" × 8,66"
|
||||
Enter bookmark name: Inserisci il nome del segnalibro:
|
||||
This document has no bookmarks. Questo documento non ha segnalibri.
|
||||
Find “{text}” in the document Trova “{text}” nel documento
|
||||
Type a page number, or +2 / -1. Digita un numero di pagina, oppure +2 / -1.
|
||||
Wait for the print job to finish submitting before closing. Attendi che l'invio del lavoro di stampa sia terminato prima di chiudere.
|
||||
template Letter Lettera
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2228,6 +2228,7 @@
|
||||
Submitting print job… 印刷ジョブを送信しています…
|
||||
Print job submitted. 印刷ジョブを送信しました。
|
||||
Print job submitted: {receipt} 印刷ジョブを送信しました:{receipt}
|
||||
Print job submitted: spooled {count} sheet(s) 印刷ジョブを送信しました: {count} 枚をスプールしました
|
||||
Print failed: the printing worker stopped. 印刷に失敗しました:印刷ワーカーが停止しました。
|
||||
Print settings 印刷設定
|
||||
Submit print job 印刷ジョブの送信
|
||||
@@ -2351,3 +2352,7 @@
|
||||
Deep ディープ
|
||||
Lit from Bottom 下から照明
|
||||
Center Glow 中央の光彩
|
||||
# File › Home greeting
|
||||
Good morning おはようございます
|
||||
Good afternoon こんにちは
|
||||
Good evening こんばんは
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 928 and column 23.
|
@@ -1100,6 +1100,7 @@
|
||||
Submitting print job… 인쇄 작업 전송 중…
|
||||
Print job submitted. 인쇄 작업을 전송했습니다.
|
||||
Print job submitted: {receipt} 인쇄 작업을 전송했습니다: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) 인쇄 작업을 보냈습니다: {count}장을 스풀했습니다
|
||||
Print failed: the printing worker stopped. 인쇄 실패: 인쇄 작업자가 중지되었습니다.
|
||||
Couldn't open {path}: {error} {path}을(를) 열 수 없습니다: {error}
|
||||
Column Selection 열 선택
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 934 and column 23.
|
@@ -2628,6 +2628,7 @@ preview Title Tytuł
|
||||
Submitting print job… Wysyłanie zadania drukowania…
|
||||
Print job submitted. Zadanie drukowania wysłane.
|
||||
Print job submitted: {receipt} Zadanie drukowania wysłane: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Zadanie drukowania wysłane: liczba arkuszy przekazanych do spoolera: {count}
|
||||
Print failed: the printing worker stopped. Drukowanie nie powiodło się: proces drukowania został zatrzymany.
|
||||
Print settings Ustawienia drukowania
|
||||
Submit print job Wyślij zadanie drukowania
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2240,6 +2240,7 @@
|
||||
Submitting print job… Enviando trabalho de impressão…
|
||||
Print job submitted. Trabalho de impressão enviado.
|
||||
Print job submitted: {receipt} Trabalho de impressão enviado: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Trabalho de impressão enviado: {count} folha(s) na fila de impressão
|
||||
Print failed: the printing worker stopped. A impressão falhou: o processo de impressão parou.
|
||||
Print settings Configurações de impressão
|
||||
Submit print job Enviar trabalho de impressão
|
||||
@@ -2363,3 +2364,7 @@
|
||||
Deep Profundo
|
||||
Lit from Bottom Iluminado de baixo
|
||||
Center Glow Brilho central
|
||||
# File › Home greeting
|
||||
Good morning Bom dia
|
||||
Good afternoon Boa tarde
|
||||
Good evening Boa noite
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2619,6 +2619,7 @@ preview Title Пример
|
||||
Submitting print job… Отправка задания на печать…
|
||||
Print job submitted. Задание на печать отправлено.
|
||||
Print job submitted: {receipt} Задание на печать отправлено: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Задание печати отправлено: в очередь передано листов: {count}
|
||||
Print failed: the printing worker stopped. Печать не удалась: процесс печати остановлен.
|
||||
Print settings Параметры печати
|
||||
Submit print job Отправить задание на печать
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2238,6 +2238,7 @@
|
||||
Submitting print job… Slanje zadatka za štampanje…
|
||||
Print job submitted. Zadatak za štampanje je poslat.
|
||||
Print job submitted: {receipt} Zadatak za štampanje je poslat: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Zadatak za štampu je poslat: u red za štampu poslato listova: {count}
|
||||
Print failed: the printing worker stopped. Štampanje nije uspelo: proces štampanja je zaustavljen.
|
||||
Print settings Podešavanja štampanja
|
||||
Submit print job Pošalji zadatak za štampanje
|
||||
@@ -2361,3 +2362,7 @@
|
||||
Deep Dubok
|
||||
Lit from Bottom Osvetljen odozdo
|
||||
Center Glow Sjaj iz sredine
|
||||
# File › Home greeting
|
||||
Good morning Dobro jutro
|
||||
Good afternoon Dobar dan
|
||||
Good evening Dobro veče
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2242,6 +2242,7 @@
|
||||
Submitting print job… Слање задатка за штампање…
|
||||
Print job submitted. Задатак за штампање је послат.
|
||||
Print job submitted: {receipt} Задатак за штампање је послат: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Задатак за штампу је послат: у ред за штампу послато листова: {count}
|
||||
Print failed: the printing worker stopped. Штампање није успело: процес штампања је заустављен.
|
||||
Print settings Подешавања штампања
|
||||
Submit print job Пошаљи задатак за штампање
|
||||
@@ -2365,3 +2366,7 @@
|
||||
Deep Дубок
|
||||
Lit from Bottom Осветљен одоздо
|
||||
Center Glow Сјај из средине
|
||||
# File › Home greeting
|
||||
Good morning Добро јутро
|
||||
Good afternoon Добар дан
|
||||
Good evening Добро вече
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -2541,6 +2541,7 @@ preview Title Titel
|
||||
Submitting print job… Skickar utskriften…
|
||||
Print job submitted. Utskriften har skickats.
|
||||
Print job submitted: {receipt} Utskriften har skickats: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Utskriftsjobbet skickades: {count} ark lades i utskriftskön
|
||||
Print failed: the printing worker stopped. Utskriften misslyckades: utskriftsprocessen avbröts.
|
||||
Wait for the print job to finish submitting before closing. Vänta tills utskriften har skickats innan du stänger.
|
||||
Find “{text}” in the document Sök efter ”{text}” i dokumentet
|
||||
@@ -2777,3 +2778,16 @@ preview Title Titel
|
||||
Deep Djup
|
||||
Lit from Bottom Belyst nedifrån
|
||||
Center Glow Glöd i mitten
|
||||
template Letter Brev
|
||||
Style: APA Stil: APA
|
||||
➕ New comment ➕ Ny kommentar
|
||||
📂 Browse… 📂 Bläddra…
|
||||
B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33"
|
||||
B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12"
|
||||
Envelope #10 4.12" × 9.50" Kuvert #10 4,12" × 9,50"
|
||||
Envelope DL 4.33" × 8.66" Kuvert DL 4,33" × 8,66"
|
||||
Executive 7.25" × 10.50" Executive 7,25" × 10,50"
|
||||
Legal 8.50" × 14.00" Legal 8,50" × 14,00"
|
||||
Letter 8.50" × 11.00" Letter 8,50" × 11,00"
|
||||
Statement 5.50" × 8.50" Statement 5,50" × 8,50"
|
||||
Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00"
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -152,6 +152,22 @@ fn every_tab_and_command_is_translated() {
|
||||
assert!(missing.is_empty(), "untranslated: {missing:#?}");
|
||||
}
|
||||
|
||||
/// File › Home opens with a greeting for the time of day (`backstage::home_page`); every
|
||||
/// language has all three, so the page doesn't start in English.
|
||||
#[test]
|
||||
fn the_file_home_greeting_is_translated_in_every_language() {
|
||||
let mut missing = Vec::new();
|
||||
for l in Lang::all().filter(|l| *l != Lang::EN) {
|
||||
for s in ["Good morning", "Good afternoon", "Good evening"] {
|
||||
if !has(l, s) {
|
||||
missing.push(format!("{}: {s:?}", l.code()));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "untranslated: {missing:#?}");
|
||||
assert_eq!(tr(lang("pt-br"), "Good afternoon"), "Boa tarde");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_print_settings_and_printer_feedback_are_translated() {
|
||||
let korean = lang("ko");
|
||||
@@ -1560,3 +1576,11 @@ fn bundled_interface_fonts_cover_vietnamese_letters() {
|
||||
}
|
||||
assert!(!lang("vi").uses_cjk());
|
||||
}
|
||||
|
||||
/// #3317: the spooler's English receipt is not spliced into a translated sentence.
|
||||
#[test]
|
||||
fn spooled_print_receipt_is_translated() {
|
||||
let german = lang("de");
|
||||
let msg = crate::printing::feedback(&crate::printing::Feedback::Submitted("spooled 2 sheet(s)".into()), |s| tr(german, s).to_owned());
|
||||
assert_eq!(msg, "Druckauftrag gesendet: 2 Blatt an den Spooler übergeben");
|
||||
}
|
||||
|
||||
@@ -2618,6 +2618,7 @@ preview Title Örnek
|
||||
Submitting print job… Yazdırma işi gönderiliyor…
|
||||
Print job submitted. Yazdırma işi gönderildi.
|
||||
Print job submitted: {receipt} Yazdırma işi gönderildi: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Yazdırma işi gönderildi: {count} sayfa kuyruğa alındı
|
||||
Print failed: the printing worker stopped. Yazdırma başarısız oldu: yazdırma işlemi durdu.
|
||||
Print settings Yazdırma ayarları
|
||||
Submit print job Yazdırma işini gönder
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 802 and column 23.
|
@@ -2240,6 +2240,7 @@
|
||||
Submitting print job… Надсилання завдання на друк…
|
||||
Print job submitted. Завдання на друк надіслано.
|
||||
Print job submitted: {receipt} Завдання на друк надіслано: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Завдання друку надіслано: у чергу передано аркушів: {count}
|
||||
Print failed: the printing worker stopped. Друк не вдався: процес друку зупинився.
|
||||
Print settings Параметри друку
|
||||
Submit print job Надіслати завдання на друк
|
||||
@@ -2363,3 +2364,7 @@
|
||||
Deep Глибокий
|
||||
Lit from Bottom Освітлення знизу
|
||||
Center Glow Сяйво з центру
|
||||
# File › Home greeting
|
||||
Good morning Доброго ранку
|
||||
Good afternoon Добрий день
|
||||
Good evening Добрий вечір
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -2564,6 +2564,7 @@
|
||||
Submitting print job… Đang gửi lệnh in…
|
||||
Print job submitted. Đã gửi lệnh in.
|
||||
Print job submitted: {receipt} Đã gửi lệnh in: {receipt}
|
||||
Print job submitted: spooled {count} sheet(s) Đã gửi lệnh in: đã đưa {count} tờ vào hàng đợi
|
||||
Print failed: the printing worker stopped. In thất bại: tiến trình in đã dừng.
|
||||
Print settings Cài đặt in
|
||||
Submit print job Gửi lệnh in
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2240,6 +2240,7 @@
|
||||
Submitting print job… 正在提交打印作业…
|
||||
Print job submitted. 打印作业已提交。
|
||||
Print job submitted: {receipt} 打印作业已提交:{receipt}
|
||||
Print job submitted: spooled {count} sheet(s) 打印作业已发送:已将 {count} 张纸送入后台处理
|
||||
Print failed: the printing worker stopped. 打印失败:打印工作线程已停止。
|
||||
Print settings 打印设置
|
||||
Submit print job 提交打印作业
|
||||
@@ -2363,3 +2364,7 @@
|
||||
Deep 深沉
|
||||
Lit from Bottom 底部照亮
|
||||
Center Glow 中心光晕
|
||||
# File › Home greeting
|
||||
Good morning 早上好
|
||||
Good afternoon 下午好
|
||||
Good evening 晚上好
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2229,6 +2229,7 @@
|
||||
Submitting print job… 正在提交列印工作…
|
||||
Print job submitted. 列印工作已提交。
|
||||
Print job submitted: {receipt} 列印工作已提交:{receipt}
|
||||
Print job submitted: spooled {count} sheet(s) 列印工作已傳送:已將 {count} 張紙送入背景處理
|
||||
Print failed: the printing worker stopped. 列印失敗:列印工作執行緒已停止。
|
||||
Print settings 列印設定
|
||||
Submit print job 提交列印工作
|
||||
@@ -2352,3 +2353,7 @@
|
||||
Deep 深沉
|
||||
Lit from Bottom 底部照亮
|
||||
Center Glow 中央光暈
|
||||
# File › Home greeting
|
||||
Good morning 早安
|
||||
Good afternoon 午安
|
||||
Good evening 晚安
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 929 and column 23.
|
@@ -1969,6 +1969,13 @@ impl WordApp {
|
||||
};
|
||||
let text = printing::feedback(&message, |label| i18n::t(label).to_owned());
|
||||
self.status(text);
|
||||
// Word returns to the document once a print job is away, the same as after Save; a
|
||||
// failure stays on the Print page so the error (and the settings to fix it) are visible
|
||||
// (#3210: the Print page used to stay up after a successful submission).
|
||||
if matches!(message, printing::Feedback::Submitted(_)) && self.ui.backstage && self.ui.backstage_page == "print" {
|
||||
self.ui.backstage = false;
|
||||
self.canvas.want_focus = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops.
|
||||
@@ -5585,6 +5592,74 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2958: the Print button leads the settings, above the printer and the settings it prints
|
||||
/// with (as in Word), and it wears the accent the app gives a dialog's primary button, so the
|
||||
/// page's one action is found first and reads as one rather than as another setting.
|
||||
#[test]
|
||||
fn the_print_button_leads_the_settings_and_is_the_primary_action() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::{By, Queryable};
|
||||
const BLURB: &str = "Send the document straight to the system print dialog, or save a copy in another format below.";
|
||||
let mut a = app();
|
||||
a.services.print = Some(Box::new(|_| Ok(())));
|
||||
a.run("file.print", json!({})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
// The page's own Print button (the backstage list has a Print entry too): the last.
|
||||
let print = h.query_all(By::new().role(Role::Button).label("Print")).last().expect("a Print button").rect();
|
||||
let blurb = h.get_by_label(BLURB).rect();
|
||||
assert!(print.min.y < blurb.min.y, "the Print button leads the settings: {print:?} above {blurb:?}");
|
||||
let accent = crate::theme::Tokens::get(&h.ctx).accent;
|
||||
assert!(
|
||||
h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect == print && r.fill == accent)),
|
||||
"the Print button is the primary (accent) button"
|
||||
);
|
||||
}
|
||||
|
||||
/// #3210: a successful native print submission returns to the document, the same as Word —
|
||||
/// previously the Print page stayed up until the user clicked Back, even though the job had
|
||||
/// already gone to the spooler. A failed submission stays up instead, so the error (and the
|
||||
/// settings to fix it) are still visible.
|
||||
#[test]
|
||||
fn a_successful_native_print_returns_to_the_document() {
|
||||
use printing::Printer;
|
||||
for ok in [true, false] {
|
||||
let mut a = app();
|
||||
a.printing.printers = vec![Printer { name: "Test Printer".into(), default: true }];
|
||||
a.printing.settings.printer = "Test Printer".into();
|
||||
a.services.print_native = Some(Box::new(move |_, _, _, _| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let _ = tx.send(if ok { Ok(String::new()) } else { Err("no paper".into()) });
|
||||
rx
|
||||
}));
|
||||
a.run("file.print", json!({})).unwrap();
|
||||
assert!(a.ui.backstage && a.ui.backstage_page == "print", "the Print page is open");
|
||||
a.canvas.want_focus = false;
|
||||
a.run("ui.print", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas();
|
||||
assert_eq!(a.ui.backstage, !ok, "ok {ok}: whether the Print page is still open");
|
||||
assert_eq!(a.canvas.want_focus, ok, "ok {ok}: the document should take keyboard focus back only once the Print page actually closes");
|
||||
if !ok {
|
||||
assert_eq!(a.ui.backstage_page, "print", "the Print page is still the one showing, not some other backstage page");
|
||||
assert!(
|
||||
matches!(&a.printing.message, Some(printing::Feedback::Failed(error)) if error == "no paper"),
|
||||
"the failure is still visible: {:?}",
|
||||
a.printing.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1156: the Print page's preview fills the pane beside the settings, zooms from its own
|
||||
/// bar, scrolls both ways once the page outgrows the pane, and One Page fits it again.
|
||||
#[test]
|
||||
|
||||
@@ -147,6 +147,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
let native = app.services.print_native.is_some();
|
||||
let hook = native || app.services.print.is_some();
|
||||
// #2958: as in Word, the page's action leads the settings, in the accent the app
|
||||
// gives a dialog's primary button: the first thing seen, and plainly a button
|
||||
// that prints now rather than one more setting in the list below.
|
||||
if hook
|
||||
&& ui
|
||||
.add_enabled(!native || app.printing.ready(), crate::widgets::primary_button(ui, tl!("Print")).min_size(vec2(320.0, 34.0)))
|
||||
.clicked()
|
||||
{
|
||||
let params = app.ui.print.params();
|
||||
let _ = app.run("ui.print", params);
|
||||
}
|
||||
if native {
|
||||
if let Some(discover) = &app.services.printers {
|
||||
crate::printing::show(&mut app.printing, ui, discover, |label| crate::i18n::t(label).to_owned());
|
||||
@@ -160,18 +171,6 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
} else {
|
||||
ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer."));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
if hook
|
||||
&& ui
|
||||
.add_enabled(
|
||||
!native || app.printing.ready(),
|
||||
egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let params = app.ui.print.params();
|
||||
let _ = app.run("ui.print", params);
|
||||
}
|
||||
if ui
|
||||
.add(egui::Button::new(egui::RichText::new(tl!("Save these pages as PDF…")).font(medium(13.5))).min_size(vec2(320.0, 34.0)))
|
||||
.clicked()
|
||||
|
||||
@@ -101,6 +101,13 @@ pub fn settings(state: &mut State, params: &serde_json::Value, discover: Option<
|
||||
}))
|
||||
}
|
||||
|
||||
const SPOOLED: &str = "Print job submitted: spooled {count} sheet(s)";
|
||||
|
||||
/// The sheet count of the Windows backend's English receipt `spooled N sheet(s)`.
|
||||
fn spooled_sheets(receipt: &str) -> Option<usize> {
|
||||
receipt.trim().strip_prefix("spooled ")?.strip_suffix(" sheet(s)")?.parse().ok()
|
||||
}
|
||||
|
||||
pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> String {
|
||||
match message {
|
||||
Feedback::FindingPrinters => translate("Finding printers…"),
|
||||
@@ -108,6 +115,10 @@ pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> Strin
|
||||
Feedback::DiscoveryFailed => translate("Printer discovery failed."),
|
||||
Feedback::Submitting => translate("Submitting print job…"),
|
||||
Feedback::Submitted(receipt) if receipt.trim().is_empty() => translate("Print job submitted."),
|
||||
Feedback::Submitted(receipt) if spooled_sheets(receipt).is_some_and(|_| translate(SPOOLED) != SPOOLED) => {
|
||||
let count = spooled_sheets(receipt).unwrap_or_default();
|
||||
translate(SPOOLED).replace("{count}", &count.to_string())
|
||||
}
|
||||
Feedback::Submitted(receipt) => translate("Print job submitted: {receipt}").replace("{receipt}", receipt),
|
||||
Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", error),
|
||||
Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."),
|
||||
|
||||
@@ -160,6 +160,15 @@ pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLay
|
||||
if !available(app) {
|
||||
return;
|
||||
}
|
||||
// The caret's table shows its move handle wherever the pointer is, as in Word.
|
||||
for (page, h, _) in crate::table_resize::caret_handles(app, painter, layout, rects) {
|
||||
if let Some(h) = h
|
||||
&& let (Some(a), Some(b)) =
|
||||
(to_screen(rects, layout, scale, page, h.x, h.y), to_screen(rects, layout, scale, page, h.right(), h.bottom()))
|
||||
{
|
||||
move_handle(painter, Rect::from_two_pos(a, b), t);
|
||||
}
|
||||
}
|
||||
let Some(p) = hover else { return };
|
||||
let Some((page, x, y)) = crate::canvas::page_at(rects, layout, scale, p) else { return };
|
||||
let Some(sc) = page_scale(rects, layout, scale, page) else { return };
|
||||
|
||||
@@ -263,6 +263,16 @@ pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLay
|
||||
if !available(app) {
|
||||
return;
|
||||
}
|
||||
// The caret's table shows it wherever the pointer is, as in Word.
|
||||
for (page, _, h) in caret_handles(app, painter, layout, rects) {
|
||||
if let Some(h) = h
|
||||
&& let (Some(a), Some(b)) = (s(page, h.x, h.y), s(page, h.right(), h.bottom()))
|
||||
{
|
||||
let r = Rect::from_two_pos(a, b);
|
||||
painter.rect_filled(r, 0.0, egui::Color32::WHITE);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
// The handle shows while the pointer is over the table (or on the handle itself).
|
||||
let Some(p) = painter.ctx().input(|i| i.pointer.hover_pos()) else { return };
|
||||
let Some((page, x, y)) = crate::canvas::page_at(rects, layout, scale, p) else { return };
|
||||
@@ -276,6 +286,29 @@ pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLay
|
||||
}
|
||||
}
|
||||
|
||||
/// The move and resize handles (page points) of the caret's table on the pages in view: Word
|
||||
/// shows them while the caret is in a table, not only under the pointer.
|
||||
pub(crate) fn caret_handles(
|
||||
app: &WordApp,
|
||||
painter: &egui::Painter,
|
||||
layout: &DocLayout,
|
||||
rects: &[Rect],
|
||||
) -> Vec<(usize, Option<wordcraft_geom::Rect>, Option<wordcraft_geom::Rect>)> {
|
||||
let focus = &app.session.sel.focus;
|
||||
let Some((tp, _, _)) = focus.path.cell() else { return Vec::new() };
|
||||
let clip = painter.clip_rect();
|
||||
rects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| r.intersects(clip))
|
||||
.map(|(page, _)| {
|
||||
let (m, z) = wordcraft_engine::cmd::table_mouse::caret_handles(layout, &app.session.doc, focus.story, &tp, page);
|
||||
(page, m, z)
|
||||
})
|
||||
.filter(|(_, m, z)| m.is_some() || z.is_some())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** trivial (native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: shape gradients and transparency, #1500; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** trivial (table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: shape gradients and transparency, #1500; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
@@ -39,13 +39,12 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329) |
|
||||
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |
|
||||
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). |
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
|
||||
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL |
|
||||
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), headers and footers of each master page (every, left and first page) read on open, not yet written (#863), keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL |
|
||||
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (`<html>`, `<!DOCTYPE html>`) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
|
||||
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). Header cells (`<th>`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `<blockquote>` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified) |
|
||||
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). Header cells (`<th>`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `<blockquote>` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `<ruby>base<rt>reading</rt></ruby>` in the base text's formatting and read back from `<ruby>`/`<rb>`/`<rt>` (`<rp>` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) |
|
||||
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
|
||||
| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element |
|
||||
| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) |
|
||||
@@ -70,7 +69,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) |
|
||||
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) |
|
||||
| Positional tabs (`w:ptab`: alignment, margin or indent, leader) | ✅ | ✅ | Laid out at their place whatever the tab stops; one with an alignment or base the spec doesn't define reads as a plain tab (#2367). `crates/docx/tests/positional_tabs.rs` |
|
||||
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256), every `w:tblStylePr` part (wholeTable, firstRow, lastRow, firstCol, lastCol, band1/2Vert, band1/2Horz, ne/nw/se/swCell) with run formatting, `w:jc`, cell borders, shading and `w:vAlign` (#714); a cell's own `w:vAlign w:val="top"` is kept and beats the style's (#1288); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) |
|
||||
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256), every `w:tblStylePr` part (wholeTable, firstRow, lastRow, firstCol, lastCol, band1/2Vert, band1/2Horz, ne/nw/se/swCell) with run formatting, `w:jc`, cell borders, shading and `w:vAlign` (#714); a cell's own `w:vAlign w:val="top"` is kept and beats the style's (#1288); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362); table style band sizes (`w:tblStyleRowBandSize`, `w:tblStyleColBandSize`) are read and written, and a style without them has no bands, as Word reads it (ECMA-376 says one), while our banded styles write theirs (#3415) |
|
||||
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) |
|
||||
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Row table border exceptions (`w:tblPrEx/w:tblBorders`) round-trip (`row_table_borders_round_trip`); other row table exceptions are not preserved. Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) |
|
||||
| Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | |
|
||||
@@ -233,6 +232,9 @@ Imported text fields use the first nonempty cached result run's formatting on th
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-21 | trivial | HTML export writes ruby (furigana, phonetic guides) as `<ruby>base<rt>reading</rt></ruby>`, in the base text's run formatting, and HTML import reads `<ruby>`/`<rb>`/`<rt>` back into one ruby object: `<rp>` fallback parentheses and the tags are not text, an unclosed element ends at the next block, and malformed ruby keeps its text. HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) |
|
||||
| 2026-10-21 | trivial | RTF import (#1653): the East Asian multibyte code pages are decoded — 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC/windows-949 (Korean) and 950 Big5 (Traditional Chinese), named by `\ansicpg`, a font's `\cpg` or `\fcharset128`/`129`/`134`/`136` (and the OEM charset 255 as 437), escaped (`\'hh`) or raw. Bytes are decoded as a run, so a lead byte and its trail byte decode together; a code page switch, a group end and the end of the document decode what is pending; invalid sequences read as U+FFFD and never lose the rest of the text. The decoder does no BOM sniffing, so a run that starts `EF BB BF` in GBK, Big5 or UHC stays text, and ASCII runs and picture hex no longer decode byte by byte. Single-byte code pages, `\uN?` escapes and their `\ucN` skips are unchanged |
|
||||
| 2026-10-21 | trivial | Tables: table style band sizes (`w:tblStyleRowBandSize`, `w:tblStyleColBandSize`) are written for our banded styles and read with zero kept; a style without them has no bands, as in Word, so banded rows shade the same rows in both (#3415) |
|
||||
| 2026-10-19 | trivial | An equation from LibreOffice in an `.odt` file no longer shows blank: it's embedded as a `draw:object` formula sub-document (MathML), with a `draw:image` replacement preview in LibreOffice's own metafile format WordCraft can't decode as a picture; the formula's own `content.xml` is read instead, through the same MathML reader as HTML (#3099) |
|
||||
| 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in |
|
||||
| 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey |
|
||||
@@ -299,6 +301,7 @@ Imported text fields use the first nonempty cached result run's formatting on th
|
||||
| 2026-10-14 | trivial | RTF reads and writes tracked insertions and deletions with their authors and dates (gap 19); deleted text no longer opens as normal text |
|
||||
| 2026-10-14 | trivial | ODT import reads sections: a paragraph naming another master page starts a new-page section with that page's size, orientation, margins and columns, and its `style:page-number` restart; a columned `text:section` is a continuous section with its columns (#1379) |
|
||||
| 2026-10-14 | trivial | PDF export writes right-to-left runs' glyphs in visual order, so Chrome, Edge (PDFium) and poppler extract Hebrew and Arabic as whole words (vowelled words keep their letters in order but may extract with spaces inside), and Ctrl+F finds them (#1383). `/ReversedChars` (for strict file-order readers) covers plain runs and vowelled runs whose marks split into their own glyphs, not runs with ligatures or mixed directions |
|
||||
| 2026-10-16 | trivial | PDF export: right-to-left clusters drawn at the same x (lam-alef as a lam and a zero-width alef, as in the static Noto Naskh Arabic builds) are written in reverse text order, so the run keeps `/ReversedChars` and السلام no longer extracts as السالم (#2310) |
|
||||
| 2026-10-14 | trivial | HTML: `<th>` bold, cell `text-align` (own or inherited) reaches the cell's paragraphs; HTML, Markdown and plain text open on the region's paper, in the app and `wordcraft-cli` (#1381) |
|
||||
| 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) |
|
||||
| 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (an equation-array row without `&` is centred, #2617; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** trivial (a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
|
||||
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
|
||||
@@ -60,7 +60,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
|
||||
| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 |
|
||||
| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — |
|
||||
| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 |
|
||||
| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; other overlap rules untested | 2–3 |
|
||||
| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); other overlap rules untested | 2–3 |
|
||||
| Nested tables | 🟡 | render; polish missing | 2–4 |
|
||||
| Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell | — |
|
||||
| Shape fills: gradients and transparency | 🟡 | #1500: linear (angle, `scaled`) and circular gradients and transparent fills drawn on screen, PNG and PDF (axial/radial shadings), soft edges fading the real fill; rectangular and shape-following path gradients drawn circular, `tileRect`/`flip` not drawn, outline gradients drawn in their middle colour, `rotWithShape="0"` drawn as turning with the shape | 2–4 |
|
||||
@@ -216,6 +216,7 @@ Page-relative footer tables keep their physical wrap bounds without adding to fo
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-20 | trivial | A click below a floating table, outside its cells, goes to the paragraph beside or after it instead of a cell of its last row, as checked in Word (#2038) |
|
||||
| 2026-10-17 | trivial | A float above its anchor whose wrap pushes the anchor onto the next page settles with no text under it on any page (#2048 review) |
|
||||
| 2026-10-15 | trivial | A float placed above its anchor paragraph (fixed on the page, or moved up from it) has the text of the paragraphs above the anchor on its page wrap around it, as in Word, instead of running behind it (#2048) |
|
||||
| 2026-10-18 | trivial | A word after a slash (" /word") wraps whole instead of splitting between two letters (#2963) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** trivial (Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
Interface languages, script support and proofing per language. How translation works (catalog
|
||||
format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`.
|
||||
@@ -26,7 +26,7 @@ native-speaker review (human).
|
||||
`pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically.
|
||||
- Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each
|
||||
catalog covers **661 (98%)**. Some newer dialog and pane strings (Equation tab tooltips, Zotero,
|
||||
Read Aloud, Backstage greetings) aren't wrapped in `tl!` yet, so they show in English in every
|
||||
Read Aloud) aren't wrapped in `tl!` yet, so they show in English in every
|
||||
language: the true share of visible strings is **~93–95%** (estimated).
|
||||
- Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20).
|
||||
- **Proofing: English, Polish, Portuguese, Italian, Russian, Czech and Indonesian**, chosen per run from its language (`w:lang`, resolved through
|
||||
@@ -54,7 +54,7 @@ shared items below.
|
||||
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (no proofing) | 1–2 (proofing) |
|
||||
| Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) |
|
||||
| Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 |
|
||||
| Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; no vertical text, ruby or document grid | no | partial | 3–5 |
|
||||
| Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; ruby (Phonetic Guide) over its base text, kept by HTML export/import (#1681); no vertical text or document grid | no | partial | 3–5 |
|
||||
| German | de | 2,021 entries; same key set as zh-hans (#574); 98% of extracted, ~95% of visible | most | Latin ✅ | no | partial | 1–2 |
|
||||
| Korean | ko | 2,463 entries (all catalog keys and preview context) | most; newer panes English | Hangul ✅ (Korean-first embedded/system UI font fallback); macOS Korean IME fixes pending (#163, #164) | no | partial | 3–5 |
|
||||
| Vietnamese | vi | 2,448 entries; every key of the reference catalogs (#1434); 98% of extracted, ~95% of visible | most | Latin ✅ | no | partial | 0–1 (review) |
|
||||
@@ -200,7 +200,9 @@ WordCraft keeps 2,54 cm).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-21 | trivial | Japanese row: ruby (Phonetic Guide) is supported over its base text and HTML export/import keeps it (#1681); the row had listed ruby as missing |
|
||||
| 2026-10-20 | trivial | Russian sorting keeps ё with е and recognises decomposed ё/й; synthetic ordering, case and index-heading regressions; broader parity estimates unchanged |
|
||||
| 2026-10-20 | trivial | File › Home's greeting (Good morning / afternoon / evening) added to the nine catalogs that lacked it (de, et, ja, pt-br, sr, sr-latn, uk, zh-hans, zh-hant); a test keeps it in every language |
|
||||
| 2026-10-14 | trivial | The sentence-capital rule (English, Polish, Croatian, Italian, Turkish, Portuguese) skips the first word of a list item (#1741) |
|
||||
| 2026-10-16 | minor | Indonesian proofing (#498): id-ID spelling from the installed Hunspell `id_ID` dictionary (not bundled), Czech's dictionary-driven suggestions, repeated-word (hyphenated reduplication offered) and spacing checks with Indonesian messages; Review › Language, the status bar, Set Proofing Language and File › Options › Proofing list Indonesian; estimates unchanged |
|
||||
| 2026-10-16 | trivial | Read Aloud picks a voice for the sentence's language (run tag, else script) when the default voice doesn't speak it, reads Windows text as UTF-8 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Typing parity with Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** trivial (Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** trivial (Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
What Word does when you type, observed black-box (same keystrokes typed into Word and
|
||||
WordCraft, results read back paragraph by paragraph) and pinned by tests in
|
||||
@@ -78,9 +78,9 @@ formatting of the text before the cursor. Not yet compared side by side in Word.
|
||||
| Delete a range from the end of one paragraph into a later one (tracking on) | the paragraph marks and text in the range are tracked deletions. Accept All removes exactly those, so the paragraph after the range stays a paragraph of its own; Reject All restores everything. Typing into an empty paragraph and rejecting leaves that paragraph | `tests::accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph`, `tests::reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph` |
|
||||
| Backspace just after a section break / Delete just before it | the break goes and the two paragraphs join; the text before it takes the following section's page setup (Word's documented behaviour: a break holds the setup of the section before it); headers and footers the following section showed by linking to the previous one stay. Delete at the end of the last paragraph removes a break on its mark too, with no paragraph to join (with Track Changes on it still does nothing there). Undo brings the break back. Breaks read from a .docx and breaks inserted here alike (#2127) | `backspace_and_delete_remove_a_section_break`, `delete_removes_a_section_break_on_the_last_paragraph` |
|
||||
| Select All, then Delete or Backspace | the whole document goes, tables included, also when it starts with a table; one empty paragraph is left | `select_all_then_delete_removes_tables` |
|
||||
| Backspace / Delete with a whole table selected (Select Table, or every cell dragged over) | Backspace removes the table, like Delete Table (one undo step; with tracking on its text is marked deleted and the structure stays); the caret goes to the paragraph after it. Delete clears the cells and keeps the table. A nested table goes on its own and leaves the cell's paragraph; a table that was the only block leaves one empty paragraph | `backspace_with_the_whole_table_selected_removes_the_table`, `delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table`, `backspace_on_a_selected_nested_table_removes_only_it`, `backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph`, `tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table` |
|
||||
| Backspace / Delete with a whole table selected (Select Table, or every cell dragged over) | Backspace removes the table, like Delete Table (one undo step; with tracking on its text is marked deleted and the structure stays); the caret goes to the paragraph after it. Delete clears the cells and keeps the table (each cell as below). A nested table goes on its own and leaves the cell's paragraph; a table that was the only block leaves one empty paragraph | `backspace_with_the_whole_table_selected_removes_the_table`, `delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table`, `backspace_on_a_selected_nested_table_removes_only_it`, `backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph`, `tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table` |
|
||||
| Backspace / Delete with a table and the paragraph before or after it selected | both remove the table, whatever end the selection starts in; the text around joins. Any table the selection covers whole goes, at any nesting level | `delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table`, `delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table` |
|
||||
| Backspace / Delete with whole rows or columns selected | Backspace deletes the rows or columns (Delete Rows / Delete Columns); Delete clears their cells. One selected cell is just that cell, also in a one-row or one-column table: Backspace clears it. **Differs:** in a 1×1 table, Select Cell then Backspace removes the table (WordCraft has no end-of-row mark to tell the cell from the whole table; the end-of-row caret position is still missing, #1844) | `backspace_deletes_selected_rows_and_delete_clears_them`, `backspace_deletes_selected_columns_and_delete_clears_them`, `backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it`; `delete_range` edge cases: `edit::tests::a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table` and five more (other tables, nested tables, story ends, headers and footnotes, content controls, nesting past the paragraph walk) |
|
||||
| Backspace / Delete with whole rows or columns selected | Backspace deletes the rows or columns (Delete Rows / Delete Columns); Delete clears their cells. One selected cell is just that cell, also in a one-row or one-column table: Backspace clears it. A cleared cell is left with one empty paragraph, however many it had, with the formatting of its last paragraph (whose mark ends the cell); a table nested in it goes too (checked against Word 2019 for Windows, #2802). With Track Changes on, Delete still marks the cells' text deleted as before. **Differs:** in a 1×1 table, Select Cell then Backspace removes the table (WordCraft has no end-of-row mark to tell the cell from the whole table; the end-of-row caret position is still missing, #1844) | `backspace_deletes_selected_rows_and_delete_clears_them`, `backspace_deletes_selected_columns_and_delete_clears_them`, `backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it`, `delete_on_whole_cells_leaves_one_empty_paragraph_in_each`; `delete_range` edge cases: `edit::tests::a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table` and five more (other tables, nested tables, story ends, headers and footnotes, content controls, nesting past the paragraph walk) |
|
||||
| Table Layout › Delete Table / Rows / Columns / Cells (tracking on) | like Word deleting a selected table: the cells' text is marked deleted and the table structure stays (WordCraft has no tracked row or column deletion, `w:trPr/w:del`, so rows and columns keep their cells too, where Word's Accept removes the rows); text you inserted yourself is just removed; Reject All restores the text. A table, row or column with no text at all (empty, or its deletion already accepted) is removed as with tracking off; one whose text is all already marked deleted is left alone with a message (accept or reject first). Delete Columns leaves a merged cell that also spans a kept column alone (it narrows, keeping its text). A content control locked against deletion, or content locked against editing, refuses the delete with tracking on or off | `tests_revision_checks::tracked_table_delete_marks_the_text_deleted_and_reject_all_restores_it`, `tracked_row_column_and_cell_deletes_mark_their_text_deleted`, `tracked_table_delete_over_tracked_insertions_rejects_back_to_the_original`, `table_deletes_refuse_a_control_locked_against_deletion`, `tracked_delete_table_removes_a_table_with_no_live_text`, `delete_columns_leaves_a_merged_cell_that_keeps_a_column_alone`, `tracked_row_and_column_deletes_over_tracked_changes_reject_back_to_the_original` |
|
||||
| Change Case (Shift+F3, Home › Aa) (tracking on) | Word shows no insertion or deletion markup (checked with Formatting markup set to none, so Word may record it as a hidden formatting revision). **Differs:** WordCraft records the changed letters as a tracked deletion of the old and insertion of the new, since case isn't a run property a formatting revision could hold; Reject All restores the old case, Accept All keeps the new. Your own tracked insertion is rewritten in place; already-deleted text is left alone and doesn't count (with tracking off too), so changing case again (or Shift+F3) works from the visible text. Refused in content locked against editing | `tests_revision_checks::tracked_change_case_rejects_back_to_the_original_case`, `tracked_change_case_of_own_insertion_stays_an_insertion`, `tracked_change_case_twice_reads_only_the_live_text`, `tracked_change_case_handles_sharp_s_and_mixed_formatting`, `change_case_refuses_content_locked_against_editing` |
|
||||
| Unlink Fields (Ctrl+Shift+F9) (tracking on) | not tracked, like Word: the field's result stays as ordinary text. Refused in content locked against editing | `tests_revision_checks::tracked_unlink_keeps_the_result_as_plain_text`, `unlink_refuses_a_field_in_content_locked_against_editing` |
|
||||
@@ -334,6 +334,7 @@ again.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-19 | trivial | Input methods: boxes that keep the keyboard ask for focus only while they lack it, so the input method isn't reset every frame (#2986) |
|
||||
| 2026-10-19 | trivial | Delete on whole selected cells (rows, columns, the table, a cell) leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too (#2802) |
|
||||
| 2026-10-21 | trivial | Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459) |
|
||||
| 2026-10-18 | trivial | Typing over a selection takes the first selected character's formatting, also when the selection was made right to left (#2879) |
|
||||
| 2026-10-17 | trivial | A table inserted in a right-to-left paragraph is a right-to-left table, so Tab moves to the cell on the left (#2634) |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (personal templates in File › New and Save as Word Template, #2852; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (the table handles show while the caret is in the table, #2428; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch.
|
||||
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
|
||||
@@ -38,7 +38,7 @@ Document title opens Rename Document; the account badge opens Options › Genera
|
||||
| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — |
|
||||
| On-canvas objects: select, drag, Ctrl+drag to copy, resize handles, nudge | ✅ tested (#228, #1014) | ✅ | 75% | — |
|
||||
| Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); crop handles: Picture Format › Crop puts the picture in crop mode, black crop handles trim a side (a corner, two) keeping the scale and the visible part in place, the cropped-away part shows dimmed, Esc/Enter/a click outside/Crop again keep the crop, one undo step per drag (unturned pictures; no Crop to Shape, Aspect Ratio or Fill/Fit); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Smart guides missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 75% | 1–2 |
|
||||
| Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it (a nested table's within the cell around it, #971); selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows, their cells shaded whole (#971). Missing: a single selected cell's border; The Cell Size Height and Width boxes set every selected row and column (#1242). | ✅ | 95% | 1 |
|
||||
| Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it (a nested table's within the cell around it, #971); selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); both handles also show while the caret is in the table, as in Word (#2428); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows, their cells shaded whole (#971). Missing: a single selected cell's border; The Cell Size Height and Width boxes set every selected row and column (#1242). | ✅ | 95% | 1 |
|
||||
| Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 |
|
||||
| Zoom: Ctrl+scroll (only zooms; a coast or notch still gliding stops, #1743), pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251), still one per row after One Page or Page Width (#768) | ✅ | ✅ | 85% | — |
|
||||
| Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237), selection bar in the left margin (click a line, double-click a paragraph, triple-click or Ctrl+click everything, drag by lines, Shift+click extends; #856) | ✅ Missing: the selection bar in the right margin for right-to-left paragraphs, beside table rows, and Word's right-pointing arrow cursor (egui has none; the plain arrow shows) | ✅ | 92% | 1 |
|
||||
@@ -75,6 +75,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-21 | trivial | File › Recover uses the same fills and text colours as the other Backstage pages; it no longer draws black with forced white text in the light theme (#3243, part of #2348) |
|
||||
| 2026-10-20 | trivial | Tables on the page: the move and resize handles show while the caret is in the table, not only under the pointer (#2428) |
|
||||
| 2026-10-18 | trivial | Long navigation headings wrap onto further lines inside the pane instead of ending in an ellipsis; the row grows with the text and the pane still resizes (#759, #3015) |
|
||||
| 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) |
|
||||
| 2026-10-16 | trivial | Dragging the vertical scrollbar shows a tip beside the pointer with the page at the top of the window ("Page 7 of 10"); nothing said where the drag would land (#2551) |
|
||||
|
||||
Reference in New Issue
Block a user