Import/export fixes: TXT encodings and list labels, RTF Unicode, ODT styles and cell colours, HTML borders and pre spaces, LaTeX alignment groups (#433–#440) (#482)
- TXT: the engine opens and saves .txt through wordcraft-formats (BOM/unmarked UTF-16 sniffing, Windows-1252 fallback; list labels and a tab on export) instead of its own narrower decoder and body plain text. - RTF: \uN and \ucN are handled in document info, bookmark names and field instructions, not only in body text. - ODT: common paragraph styles (and common parents of automatic styles) give their text formatting, except the style that sets the paragraph kind; table-cell-properties fo:background-color becomes cell shading. - HTML: tables without style or visible borders export without cell borders; white-space:pre paragraphs keep leading/trailing spaces. - LaTeX: brace groups restore the paragraph alignment set inside them. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
5078d9dea5
commit
1581f30176
|
|
@ -94,7 +94,8 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu
|
|||
return Ok((doc, true));
|
||||
}
|
||||
let mut doc = match ext.as_str() {
|
||||
"txt" | "text" | "" => Document::from_text(&decode_text(bytes)),
|
||||
// Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252.
|
||||
"txt" | "text" | "" => wordcraft_formats::txt::import(bytes),
|
||||
"json" => serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?,
|
||||
other => match crate::io_ext::open(other, bytes) {
|
||||
Some(r) => r?,
|
||||
|
|
@ -109,7 +110,8 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu
|
|||
pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
|
||||
let ext = ext_of(name);
|
||||
match ext.as_str() {
|
||||
"txt" | "text" => Ok(doc.plain_text(wordcraft_doc::StoryRef::Body).replace('\n', "\r\n").into_bytes()),
|
||||
// List paragraphs get their label and a tab, as in Word's plain-text save.
|
||||
"txt" | "text" => Ok(wordcraft_formats::txt::export(doc)),
|
||||
"json" => serde_json::to_vec_pretty(doc).map_err(|e| e.to_string()),
|
||||
other => match crate::io_ext::save(other, doc) {
|
||||
Some(r) => r,
|
||||
|
|
@ -278,19 +280,8 @@ pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option
|
|||
Err(format!("{}: files are saved through the browser on the web", path.display()))
|
||||
}
|
||||
|
||||
/// UTF-8 (with or without BOM), UTF-16 with BOM, else Latin-1.
|
||||
/// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else
|
||||
/// Windows-1252 (see [`wordcraft_formats::txt::decode`]).
|
||||
pub fn decode_text(b: &[u8]) -> String {
|
||||
if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
||||
return String::from_utf8_lossy(rest).into_owned();
|
||||
}
|
||||
if b.len() >= 2 && (b[0] == 0xFF && b[1] == 0xFE || b[0] == 0xFE && b[1] == 0xFF) {
|
||||
let le = b[0] == 0xFF;
|
||||
let units: Vec<u16> =
|
||||
b[2..].as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect();
|
||||
return String::from_utf16_lossy(&units);
|
||||
}
|
||||
match std::str::from_utf8(b) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => b.iter().map(|c| *c as char).collect(),
|
||||
}
|
||||
wordcraft_formats::txt::decode(b)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,28 @@ fn doc_extension_dispatches_to_docbin() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txt_open_decodes_windows_1252_and_unmarked_utf16() {
|
||||
// #433: the TXT open path sniffs encodings like the formats importer.
|
||||
let open = |b: &[u8]| crate::io::open_bytes("x.txt", b).unwrap().plain_text(StoryRef::Body);
|
||||
assert_eq!(open(b"\x80"), "€");
|
||||
assert_eq!(open(b"A\0B\0"), "AB");
|
||||
assert_eq!(open(b"\0A\0B"), "AB");
|
||||
assert_eq!(open("€".as_bytes()), "€");
|
||||
assert_eq!(open(b"\xFF\xFEA\0B\0"), "AB");
|
||||
assert_eq!(open(b"A\0B"), "AB", "odd byte count stays safe");
|
||||
assert_eq!(crate::io::decode_text(b"caf\xE9 \x80"), "café €");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txt_save_writes_list_labels() {
|
||||
// #434: list paragraphs keep their bullet or number and a tab in plain text.
|
||||
let doc = crate::io::open_bytes("l.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
|
||||
assert_eq!(crate::io::save_bytes("l.txt", &doc).unwrap(), "•\talpha\r\n•\tbeta\r\n1.\tfirst\r\n2.\tsecond".as_bytes());
|
||||
let plain = crate::io::open_bytes("p.md", b"alpha\n\nbeta\n").unwrap();
|
||||
assert_eq!(crate::io::save_bytes("p.txt", &plain).unwrap(), b"alpha\r\nbeta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_has_unique_id() {
|
||||
let reg = cmd::registry();
|
||||
|
|
|
|||
|
|
@ -383,6 +383,10 @@ struct Builder<'r> {
|
|||
para: Option<Para>,
|
||||
/// The current paragraph continues a list item (joins the previous paragraph).
|
||||
para_cont: bool,
|
||||
/// Whether the current paragraph's first / last text was read with preserved white space
|
||||
/// (CSS `white-space: pre…`): those edge spaces are kept when the paragraph ends.
|
||||
pre_lead: Option<bool>,
|
||||
pre_tail: bool,
|
||||
meta: Meta,
|
||||
title: Option<String>,
|
||||
}
|
||||
|
|
@ -575,6 +579,8 @@ impl<'r> Builder<'r> {
|
|||
stack: Vec::new(),
|
||||
para: None,
|
||||
para_cont: false,
|
||||
pre_lead: None,
|
||||
pre_tail: false,
|
||||
meta: Meta::default(),
|
||||
title: None,
|
||||
}
|
||||
|
|
@ -643,8 +649,17 @@ impl<'r> Builder<'r> {
|
|||
|
||||
fn flush(&mut self) {
|
||||
let Some(mut p) = self.para.take() else { return };
|
||||
let (pre_lead, pre_tail) = (self.pre_lead.take().unwrap_or(false), std::mem::take(&mut self.pre_tail));
|
||||
if p.kind != Kind::Code {
|
||||
p.trim();
|
||||
// Preserved white space keeps its spaces and tabs at the edges; line breaks there
|
||||
// (source formatting) still go.
|
||||
const ALL: &[char] = &[' ', '\t', '\r', '\n'];
|
||||
const BREAKS: &[char] = &['\r', '\n'];
|
||||
p.trim_with(if pre_lead { BREAKS } else { ALL }, if pre_tail { BREAKS } else { ALL });
|
||||
if p.text().trim().is_empty() {
|
||||
// Only white space (indentation between blocks): nothing to keep.
|
||||
p.trim();
|
||||
}
|
||||
}
|
||||
if p.inlines.len() == 1 && p.text() == "\u{00A0}" {
|
||||
// `<p> </p>`: an empty paragraph on purpose.
|
||||
|
|
@ -712,6 +727,7 @@ impl<'r> Builder<'r> {
|
|||
return;
|
||||
}
|
||||
if self.preserve() {
|
||||
self.note_edge(true);
|
||||
self.start_para().push_text(t, &f);
|
||||
return;
|
||||
}
|
||||
|
|
@ -736,9 +752,18 @@ impl<'r> Builder<'r> {
|
|||
if s.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.note_edge(false);
|
||||
self.start_para().push_text(&s, &f);
|
||||
}
|
||||
|
||||
/// Record whether text about to be added keeps its white space (see `pre_lead`).
|
||||
fn note_edge(&mut self, preserved: bool) {
|
||||
if self.para.as_ref().is_none_or(|p| p.inlines.is_empty()) {
|
||||
self.pre_lead = Some(preserved);
|
||||
}
|
||||
self.pre_tail = preserved;
|
||||
}
|
||||
|
||||
fn pre_fresh(&self) -> bool {
|
||||
self.stack.iter().rev().find(|e| e.kind == ElKind::Pre).is_some_and(|e| !e.used)
|
||||
}
|
||||
|
|
@ -1294,7 +1319,10 @@ fn table_html(t: &FTable, out: &mut String, depth: usize) {
|
|||
if c.rowspan > 1 {
|
||||
attrs.push_str(&format!(" rowspan=\"{}\"", c.rowspan));
|
||||
}
|
||||
let mut css = vec!["border:1px solid #999".to_string(), "padding:4px 6px".to_string(), "vertical-align:top".to_string()];
|
||||
let mut css = vec!["padding:4px 6px".to_string(), "vertical-align:top".to_string()];
|
||||
if !t.borderless {
|
||||
css.insert(0, "border:1px solid #999".to_string());
|
||||
}
|
||||
if let Some(s) = c.shading {
|
||||
css.push(format!("background-color:#{}", s.hex()));
|
||||
}
|
||||
|
|
@ -1474,6 +1502,31 @@ mod tests {
|
|||
assert_eq!(table_style("<table><tr><td style='border:1px solid #999'>a</table>").as_deref(), Some("TableGrid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borderless_tables_export_without_cell_borders() {
|
||||
// #438: a table without grid style and borders writes no cell borders and reopens borderless.
|
||||
for (src, grid) in [
|
||||
("<table style='border:none'><tr><td>A<td>B</table>", false),
|
||||
("<table><tr><td>A<td>B</table>", false),
|
||||
("<table border=1><tr><td>A<td>B</table>", true),
|
||||
] {
|
||||
let out = export(&crate::model::to_doc(&parse(src)));
|
||||
assert_eq!(out.contains("border:1px"), grid, "{src}: {out}");
|
||||
assert_eq!(table_style(&out).as_deref(), Some(if grid { "TableGrid" } else { "" }), "{src}");
|
||||
assert!(out.contains(">A</td>") && out.contains(">B</td>"), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_preformatted_paragraphs_keep_edge_spaces() {
|
||||
// #439: `white-space:pre` keeps leading and trailing spaces like `<pre>`; normal
|
||||
// paragraphs still collapse.
|
||||
let f = parse(
|
||||
"<p style=\"white-space:pre\"> Alpha Beta </p><p style=\"white-space:pre-wrap\">\n Gamma \n</p><pre> Alpha </pre><p> Alpha Beta </p>",
|
||||
);
|
||||
assert_eq!(texts(&f), vec!["NormalNone: Alpha Beta ", "NormalNone: Gamma ", "CodeNone: Alpha ", "NormalNone:Alpha Beta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_soup() {
|
||||
let f = parse(
|
||||
|
|
|
|||
|
|
@ -2076,12 +2076,15 @@ impl<'a> Parser<'a> {
|
|||
Tok::Tie => self.text("\u{A0}"),
|
||||
Tok::Amp => self.text(" "),
|
||||
Tok::Open => {
|
||||
// A group scopes declarations (`{\bfseries …}`).
|
||||
// A group scopes declarations (`{\bfseries …}`, `{\centering …\par}`):
|
||||
// character formatting and paragraph alignment come back at its end.
|
||||
let toks = {
|
||||
self.pos -= 1;
|
||||
self.arg_tokens()
|
||||
};
|
||||
let saved_ctx = self.ctx;
|
||||
self.inline_with(toks, |_| {});
|
||||
self.ctx = saved_ctx;
|
||||
}
|
||||
Tok::Close => {}
|
||||
Tok::Math(m, display) => {
|
||||
|
|
@ -2862,6 +2865,21 @@ Caf\'e na\"ive \c{c}a \v{s}\'{e} and {\bfseries grouped bold} after.
|
|||
\end{document}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn alignment_declarations_end_with_their_group() {
|
||||
// #440: `{\centering …\par}` centres only inside the group; the outer alignment returns.
|
||||
let align = |body: &str| -> Vec<(String, Option<Align>)> {
|
||||
paras(&format!("\\documentclass{{article}}\\begin{{document}}{body}\\end{{document}}")).iter().map(|p| (p.text(), p.align)).collect()
|
||||
};
|
||||
let a = |t: &str, al: Option<Align>| (t.to_string(), al);
|
||||
assert_eq!(align(r"{\centering Center\par}After\par"), vec![a("Center", Some(Align::Center)), a("After", None)]);
|
||||
assert_eq!(
|
||||
align(r"\raggedleft Outer\par{\centering Inner\par}Restored\par"),
|
||||
vec![a("Outer", Some(Align::Right)), a("Inner", Some(Align::Center)), a("Restored", Some(Align::Right))]
|
||||
);
|
||||
assert_eq!(align(r"\centering One\par Two\par"), vec![a("One", Some(Align::Center)), a("Two", Some(Align::Center))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn article_structure() {
|
||||
let flow = parse(ARTICLE);
|
||||
|
|
|
|||
|
|
@ -132,8 +132,15 @@ impl Para {
|
|||
|
||||
/// Remove whitespace at the very start and end of the paragraph's text.
|
||||
pub fn trim(&mut self) {
|
||||
const WS: &[char] = &[' ', '\t', '\r', '\n'];
|
||||
self.trim_with(WS, WS);
|
||||
}
|
||||
|
||||
/// Remove the characters in `start` from the start of the paragraph's text and those in
|
||||
/// `end` from its end.
|
||||
pub fn trim_with(&mut self, start: &[char], end: &[char]) {
|
||||
while let Some(Inline::Text(t, _)) = self.inlines.first_mut() {
|
||||
let n = t.trim_start_matches([' ', '\t', '\r', '\n']).len();
|
||||
let n = t.trim_start_matches(start).len();
|
||||
let cut = t.len() - n;
|
||||
t.replace_range(..cut, "");
|
||||
if t.is_empty() {
|
||||
|
|
@ -143,7 +150,7 @@ impl Para {
|
|||
}
|
||||
}
|
||||
while let Some(Inline::Text(t, _)) = self.inlines.last_mut() {
|
||||
let n = t.trim_end_matches([' ', '\t', '\r', '\n']).len();
|
||||
let n = t.trim_end_matches(end).len();
|
||||
t.truncate(n);
|
||||
if t.is_empty() {
|
||||
self.inlines.pop();
|
||||
|
|
@ -792,7 +799,11 @@ fn flow_table(doc: &Document, t: &Table, depth: usize) -> FTable {
|
|||
}
|
||||
rows.push(cells);
|
||||
}
|
||||
FTable { rows, widths: t.grid.clone(), borderless: false }
|
||||
// Without a table style and without visible table or cell borders the table shows no grid.
|
||||
let visible = |b: &Option<wordcraft_doc::props::Borders>| b.as_ref().is_some_and(|b| b.any_visible());
|
||||
let borderless =
|
||||
t.props.style.is_none() && !visible(&t.props.borders) && t.rows.iter().flat_map(|r| &r.cells).all(|c| !visible(&c.props.borders));
|
||||
FTable { rows, widths: t.grid.clone(), borderless }
|
||||
}
|
||||
|
||||
/// The document body as a flow.
|
||||
|
|
|
|||
|
|
@ -631,6 +631,8 @@ struct StyleInfo {
|
|||
page_break: bool,
|
||||
outline: Option<u8>,
|
||||
border_bottom: bool,
|
||||
/// `fo:background-color` of a table-cell style's `style:table-cell-properties`.
|
||||
cell_background: Option<Rgb>,
|
||||
}
|
||||
|
||||
/// Length → points.
|
||||
|
|
@ -786,6 +788,13 @@ impl Styles {
|
|||
}
|
||||
}
|
||||
}
|
||||
"table-cell-properties" => {
|
||||
if let Some((_, s)) = &mut cur
|
||||
&& let Some(c) = get(&a, "fo:background-color").and_then(model::parse_color)
|
||||
{
|
||||
s.cell_background = Some(c);
|
||||
}
|
||||
}
|
||||
"paragraph-properties" => {
|
||||
if let Some((_, s)) = &mut cur {
|
||||
s.align = match get(&a, "fo:text-align") {
|
||||
|
|
@ -861,11 +870,22 @@ impl Styles {
|
|||
f
|
||||
}
|
||||
|
||||
/// Paragraph kind, alignment, page break, rule and base formatting (automatic styles only).
|
||||
/// Background colour of a table-cell style (whole chain).
|
||||
fn cell_background(&self, name: &str) -> Option<Rgb> {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.cell_background)
|
||||
}
|
||||
|
||||
/// Paragraph kind, alignment, page break, rule and base formatting. The text formatting of
|
||||
/// every style in the chain applies (automatic and common, ODF 1.3 §16.2 inheritance), except
|
||||
/// the style that gives the paragraph its kind and that style's ancestors: their formatting
|
||||
/// is the kind's own look (a heading's size and colour), not direct formatting. `Standard`,
|
||||
/// the default paragraph style, holds document defaults and is left out too.
|
||||
fn para(&self, name: &str) -> (Kind, Option<Align>, bool, bool, Fmt) {
|
||||
let chain = self.chain(name);
|
||||
let mut kind = Kind::Normal;
|
||||
for (n, s) in &chain {
|
||||
let mut kind_at = chain.len();
|
||||
for (i, (n, s)) in chain.iter().enumerate() {
|
||||
kind_at = i;
|
||||
let dn = s.display.as_deref().unwrap_or(n).to_ascii_lowercase().replace("_20_", " ");
|
||||
let nn = n.to_ascii_lowercase().replace("_20_", " ");
|
||||
for cand in [dn.as_str(), nn.as_str()] {
|
||||
|
|
@ -886,12 +906,13 @@ impl Styles {
|
|||
kind = Kind::Heading(o.min(6));
|
||||
break;
|
||||
}
|
||||
kind_at = chain.len();
|
||||
}
|
||||
let align = chain.iter().find_map(|(_, s)| s.align);
|
||||
let pb = chain.iter().take_while(|(_, s)| s.auto).any(|(_, s)| s.page_break);
|
||||
let rule = chain.iter().any(|(_, s)| s.border_bottom);
|
||||
let mut f = Fmt::default();
|
||||
for (_, s) in chain.iter().rev().filter(|(_, s)| s.auto) {
|
||||
for (_, s) in chain.iter().take(kind_at).rev().filter(|(n, s)| s.auto || *n != "Standard") {
|
||||
s.fmt.apply(&mut f);
|
||||
}
|
||||
(kind, align, pb, rule, f)
|
||||
|
|
@ -1149,7 +1170,7 @@ impl Body<'_> {
|
|||
self.end_para();
|
||||
let span = get(a, "table:number-columns-spanned").and_then(|v| v.parse::<u32>().ok()).unwrap_or(1).clamp(1, 63);
|
||||
let rows = get(a, "table:number-rows-spanned").and_then(|v| v.parse::<u32>().ok()).unwrap_or(1).clamp(1, 1000);
|
||||
let shading = get(a, "table:style-name").and_then(|s| self.styles.map.get(s)).and_then(|s| s.fmt.background);
|
||||
let shading = get(a, "table:style-name").and_then(|s| self.styles.cell_background(s));
|
||||
if let Some(t) = self.tables.last_mut() {
|
||||
t.hcover = span as usize - 1;
|
||||
}
|
||||
|
|
@ -1404,4 +1425,57 @@ mod tests {
|
|||
fn rejects_garbage() {
|
||||
assert!(parse(b"not a zip").is_err());
|
||||
}
|
||||
|
||||
const NS_T: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
||||
|
||||
/// A package with `content.xml` and, when given, `styles.xml`.
|
||||
fn package(content: &str, styles: Option<&str>) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in
|
||||
[("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content)].into_iter().chain(styles.map(|s| ("styles.xml", s)))
|
||||
{
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn paras(f: &Flow) -> Vec<&Para> {
|
||||
f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_paragraph_styles_give_text_formatting() {
|
||||
// #436: a common paragraph style's text properties reach its paragraphs, directly,
|
||||
// through a derived common style and through an automatic style's parent.
|
||||
let props = r##"<style:text-properties fo:font-weight="bold" fo:color="#ff0000" fo:font-size="24pt"/>"##;
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Standard" style:family="paragraph"/><style:style style:name="Emphasis" style:family="paragraph" style:parent-style-name="Standard">{props}</style:style><style:style style:name="Derived" style:family="paragraph" style:parent-style-name="Emphasis"/></office:styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="Bridge" style:family="paragraph" style:parent-style-name="Emphasis"/></office:automatic-styles><office:body><office:text><text:p>Plain</text:p><text:p text:style-name="Emphasis">Common</text:p><text:p text:style-name="Derived">Derived</text:p><text:p text:style-name="Bridge">Bridge</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let f = parse(&package(&content, Some(&styles))).unwrap();
|
||||
let ps = paras(&f);
|
||||
assert_eq!(ps.len(), 4);
|
||||
let fmt = |p: &Para| p.inlines.iter().find_map(|i| if let Inline::Text(_, f) = i { Some(f.clone()) } else { None }).unwrap();
|
||||
assert_eq!(fmt(ps[0]), Fmt::default());
|
||||
for p in &ps[1..] {
|
||||
let f = fmt(p);
|
||||
assert!(f.bold && f.color == Some(Rgb(255, 0, 0)) && f.size == Some(24.0), "{}: {f:?}", p.text());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cell_style_background_is_shading() {
|
||||
// #437: `fo:background-color` on `style:table-cell-properties` is the cell's shading.
|
||||
let content = format!(
|
||||
r##"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="RedCell" style:family="table-cell"><style:table-cell-properties fo:background-color="#ff0000"/></style:style></office:automatic-styles><office:body><office:text><table:table><table:table-column table:number-columns-repeated="2"/><table:table-row><table:table-cell table:style-name="RedCell"><text:p>Red</text:p></table:table-cell><table:table-cell><text:p>Plain</text:p></table:table-cell></table:table-row></table:table></office:text></office:body></office:document-content>"##
|
||||
);
|
||||
let f = parse(&package(&content, None)).unwrap();
|
||||
let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") };
|
||||
let row: Vec<_> = t.rows[0].iter().map(|c| (c.text(), c.shading)).collect();
|
||||
assert_eq!(row, vec![("Red".to_string(), Some(Rgb(255, 0, 0))), ("Plain".to_string(), None)]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1048,7 +1048,9 @@ impl Reader {
|
|||
}
|
||||
return;
|
||||
}
|
||||
Dest::Info(_) | Dest::FldInst | Dest::Bookmark => return,
|
||||
// Text destinations take Unicode escapes (with fallback skipping) like the body.
|
||||
Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(w, "u" | "uc") => return,
|
||||
Dest::Info(_) | Dest::FldInst | Dest::Bookmark => {}
|
||||
Dest::Body => {}
|
||||
}
|
||||
match w {
|
||||
|
|
@ -1279,7 +1281,10 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
|||
if word == "info" {
|
||||
r.info_depth = Some(r.depth());
|
||||
}
|
||||
if r.skip_chars > 0 && r.st.dest == Dest::Body && !matches!(word, "par" | "pard" | "cell" | "row") {
|
||||
if r.skip_chars > 0
|
||||
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark)
|
||||
&& !matches!(word, "par" | "pard" | "cell" | "row")
|
||||
{
|
||||
r.skip_chars -= 1;
|
||||
first_in_group = false;
|
||||
continue;
|
||||
|
|
@ -1461,6 +1466,22 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_in_info_bookmarks_and_field_instructions() {
|
||||
// #435: `\uN` (with its `\ucN` fallback skipped) decodes in every text destination.
|
||||
let rtf = br#"{\rtf1\ansi\uc1{\info{\title Caf\u233?}{\author Caf\u233\'3f}}{\*\bkmkstart caf\u233?}{\*\bkmkend caf\u233?}{\field{\*\fldinst{HYPERLINK "https://example.test/caf\u233?"}}{\fldrslt{link}}}\par Caf\u233?\par}"#;
|
||||
let f = parse(rtf).unwrap();
|
||||
assert_eq!(f.meta.title, "Café");
|
||||
assert_eq!(f.meta.author, "Café");
|
||||
let inl: Vec<&Inline> = f.blocks.iter().flat_map(|b| if let FBlock::Para(p) = b { p.inlines.iter().collect() } else { vec![] }).collect();
|
||||
assert!(inl.iter().any(|i| matches!(i, Inline::Anchor(n) if n == "café")), "{inl:?}");
|
||||
assert!(
|
||||
inl.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "link" && f.link.as_deref() == Some("https://example.test/café"))),
|
||||
"{inl:?}"
|
||||
);
|
||||
assert_eq!(texts(&f).last().map(String::as_str), Some("Normal:Café"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_rtf() {
|
||||
assert!(parse(b"hello").is_err());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (.doc import fixes landed, #458–#463; 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-10 · **Last updated:** 2026-10-11 · **Change:** 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;
|
||||
|
|
@ -83,6 +83,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` |
|
||||
| 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) |
|
||||
| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) |
|
||||
| 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed |
|
||||
|
|
|
|||
Loading…
Reference in New Issue