diff --git a/Cargo.lock b/Cargo.lock index 39fb38094..4d0f6a9d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2173,6 +2173,7 @@ dependencies = [ "image", "jpeg-decoder", "log", + "quick-xml 0.41.0", "skrifa 0.47.0", "tiff", ] diff --git a/crates/idml/src/import/document.rs b/crates/idml/src/import/document.rs index 083f17f13..e317fd0cd 100644 --- a/crates/idml/src/import/document.rs +++ b/crates/idml/src/import/document.rs @@ -116,9 +116,11 @@ impl<'r> Importer<'r> { if vars.len() >= VAR_MAX { return; } + let pref = e.elements().find(|c| c.local().ends_with("Preference")); + let attr = |k: &str| pref.and_then(|p| p.get(k)).unwrap_or(""); let mut variable = TextVariable::new(&values.name, kind); - variable.before = values.before; - variable.after = values.after; + variable.before = if !values.before.is_empty() { values.before } else { attr("TextBefore").to_string() }; + variable.after = if !values.after.is_empty() { values.after } else { attr("TextAfter").to_string() }; if let Some(id) = values.self_id { self.text_var_ids.insert(id, vars.len()); } diff --git a/crates/idml/src/import/spread.rs b/crates/idml/src/import/spread.rs index b0c58ef13..f8df443dd 100644 --- a/crates/idml/src/import/spread.rs +++ b/crates/idml/src/import/spread.rs @@ -3,6 +3,26 @@ use super::{ Affine, Arc, Columns, Guide, Importer, ItemId, Margins, Orientation, Page, PageId, PageSide, ParentInfo, Point, Rect, Spread, SpreadId, names, }; +/// View › Rotate Spread from a `Spread` element's `ItemTransform`: quarter turns clockwise on +/// screen (IDML y points down, so `0 1 -1 0` is one clockwise turn). The spread's items and +/// pages stay in unrotated spread coordinates, so only the rotation part is read. Anything other +/// than an exact quarter turn (identity, scale, other angles, malformed numbers) is no rotation. +fn spread_view_rotation(s: Option<&str>) -> u8 { + let parsed: std::result::Result, _> = s.unwrap_or("").split_whitespace().take(7).map(str::parse::).collect(); + let Some([a, b, c, d, _, _]) = parsed.ok().and_then(|v| <[f64; 6]>::try_from(v).ok()) else { return 0 }; + let is = |x: f64, y: f64| (x - y).abs() < 1e-6; + let quarter = |m: [f64; 4]| is(a, m[0]) && is(b, m[1]) && is(c, m[2]) && is(d, m[3]); + if quarter([0.0, 1.0, -1.0, 0.0]) { + 1 + } else if quarter([-1.0, 0.0, 0.0, -1.0]) { + 2 + } else if quarter([0.0, -1.0, 1.0, 0.0]) { + 3 + } else { + 0 + } +} + impl<'r> Importer<'r> { // ---------- spreads and items ---------- @@ -95,7 +115,7 @@ impl<'r> Importer<'r> { let spine = if spine.is_finite() && facing { spine } else { 0.0 }; let show = model_spread.show_master_items(); let first_abs: usize = self.spreads.iter().map(|s| s.pages.len()).sum(); - let view_rotation = model_spread.view_rotation(); + let view_rotation = if master { 0 } else { spread_view_rotation(model_spread.values.get("ItemTransform")) }; let mut out_pages = Vec::new(); for (i, (r, p)) in pages.iter().enumerate() { let pid = PageId(self.alloc()); diff --git a/crates/idml/src/import/styles.rs b/crates/idml/src/import/styles.rs index c2a52d2fc..dba8b8cec 100644 --- a/crates/idml/src/import/styles.rs +++ b/crates/idml/src/import/styles.rs @@ -190,13 +190,18 @@ impl<'r> Importer<'r> { }); let stroke = values.stroke_enabled.then(|| self.stroke_from(e, None)); let paragraph_style = values.paragraph_style_enabled.then(|| values.paragraph_style.as_deref().map(|r| self.para_style_ref(r))).flatten(); - let text_frame = if values.text_frame_general_options { - values.text_frame_preference.map(crate::xml::from_craft).map(|preference| self.frame_options(&[&preference])) + let levels = based_on_levels(e, &objects_by_self); + let text_frame = if category_on(&levels, "EnableTextFrameGeneralOptions", |_| false) { + let prefs: Vec<&El> = levels + .iter() + .filter(|l| l.get("EnableTextFrameGeneralOptions") != Some("false")) + .filter_map(|l| l.find("TextFramePreference")) + .collect(); + (!prefs.is_empty()).then(|| self.frame_options(&prefs)) } else { None }; let effects = self.style_effects(e); - let levels = based_on_levels(e, &objects_by_self); let corners = style_corners(&levels); let apply_next_style = paragraph_style.is_some() && e.get("ApplyNextParagraphStyle") == Some("true"); let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, apply_next_style, text_frame, effects, corners }; diff --git a/crates/idml/src/import/table.rs b/crates/idml/src/import/table.rs index 58823b9ed..f36670be1 100644 --- a/crates/idml/src/import/table.rs +++ b/crates/idml/src/import/table.rs @@ -160,7 +160,13 @@ impl<'r> Importer<'r> { if r >= nr || c >= nc { continue; } - let (cs, rs) = model_cell.span(); + let (mut cs, mut rs) = model_cell.span(); + if model_cell.values.get("RowSpan").is_some_and(|v| v.eq_ignore_ascii_case("inf") || v.eq_ignore_ascii_case("infinity")) { + rs = (nr - r) as u32; + } + if model_cell.values.get("ColumnSpan").is_some_and(|v| v.eq_ignore_ascii_case("inf") || v.eq_ignore_ascii_case("infinity")) { + cs = (nc - c) as u32; + } let rs = (rs as usize).min(nr - r); let cs = (cs as usize).min(nc - c); let mut cell = t.cell(r, c).cloned().unwrap_or_default(); diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index 3ab83b451..3c7559b90 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -17,6 +17,7 @@ image = { workspace = true } skrifa = { workspace = true } log = { workspace = true } craft-cms = { workspace = true } +quick-xml = { workspace = true } craft-image-codecs = { workspace = true } craft-psd = { workspace = true } craft-file-formats = { workspace = true } diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index ab383dd73..7efabb115 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -263,8 +263,46 @@ fn add_face(db: &mut Arc, face: Arc Option<()> { + use quick_xml::events::Event; + + let text = std::str::from_utf8(bytes).ok()?; + let mut reader = quick_xml::Reader::from_str(text); + let mut depth = 0usize; + for _ in 0..MAX_SVG_EVENTS { + match reader.read_event().ok()? { + Event::Start(_) => { + depth += 1; + if depth > MAX_SVG_DEPTH { + return None; + } + } + Event::Empty(_) if depth >= MAX_SVG_DEPTH => return None, + Event::End(_) => depth = depth.checked_sub(1)?, + Event::DocType(doctype) => { + // A '[' inside a quoted external identifier is not an internal subset. + let mut quote = None; + for &byte in &doctype[..] { + match (quote, byte) { + (Some(q), b) if b == q => quote = None, + (None, b'\'' | b'"') => quote = Some(byte), + (None, b'[') => return None, + _ => {} + } + } + } + Event::Eof => return (depth == 0).then_some(()), + _ => {} + } + } + None +} + /// Parse SVG/SVGZ through craft-svg's bounded parser and data-only nested-image resolver. pub fn svg_tree(bytes: &[u8]) -> Option { + let xml = craft_svg::svgz::inflate(bytes, MAX_SVG_BYTES).ok()?; + svg_preflight(&xml)?; let limits = craft_svg::tree::Limits { bytes: MAX_SVG_BYTES, depth: MAX_SVG_DEPTH, @@ -276,7 +314,7 @@ pub fn svg_tree(bytes: &[u8]) -> Option { }; let mut options = svg_options(); options.image_href_resolver = craft_svg::tree::image_resolver(limits); - craft_svg::tree::parse(bytes, &limits, &options).ok() + craft_svg::tree::parse(&xml, &limits, &options).ok() } pub use resolution::raster_resolution; @@ -364,7 +402,7 @@ pub fn decode_rgba(bytes: &[u8]) -> Option { } let decoded = craft_image_codecs::decode_with(bytes, &decode_options()).ok()?; if decoded.layout().is_cmyk() { - let raster = cmyk_raster(&decoded)?; + let raster = cmyk_raster(&decoded, bytes)?; return to_display_rgba(Pixels::Cmyk(raster), decoded.icc.as_deref(), &DisplayColour::active()); } let rgba = image::RgbaImage::from_raw(decoded.width(), decoded.height(), decoded.to_rgba8())?; @@ -377,23 +415,70 @@ fn decode_options() -> craft_image_codecs::DecodeOptions { craft_image_codecs::DecodeOptions { limits: craft_image_codecs::Limits { max_alloc: MAX_RGBA_BYTES, ..Default::default() }, ..Default::default() } } -fn cmyk_raster(image: &craft_image_codecs::Image) -> Option { +/// Whether an APP14 "Adobe" segment comes before the image data. +fn adobe_marker(bytes: &[u8]) -> bool { + let mut i = 2; + while let Some(&[0xFF, marker, hi, lo]) = bytes.get(i..i + 4).and_then(|s| <&[u8; 4]>::try_from(s).ok()) { + // Start of scan or end of image: no more header segments. + if marker == 0xDA || marker == 0xD9 { + return false; + } + let len = usize::from(u16::from_be_bytes([hi, lo])); + if marker == 0xEE && bytes.get(i + 4..i + 9) == Some(b"Adobe".as_slice()) { + return true; + } + i += 2 + len; + } + false +} + +fn cmyk_raster(image: &craft_image_codecs::Image, bytes: &[u8]) -> Option { let converted = image.convert(craft_image_codecs::ChannelLayout::CmykA, craft_image_codecs::SampleType::U8); let pixels = usize::try_from(converted.width()).ok()?.checked_mul(usize::try_from(converted.height()).ok()?)?; let data = converted.as_u8()?; if data.len() != pixels.checked_mul(5)? { return None; } + let invert = bytes.starts_with(&[0xFF, 0xD8]) && !adobe_marker(bytes); let mut cmyk = Vec::with_capacity(pixels.checked_mul(4)?); let mut alpha = Vec::with_capacity(pixels); for &[c, m, y, k, a] in data.as_chunks::<5>().0 { - cmyk.extend([c, m, y, k]); + if invert { + cmyk.extend([255 - c, 255 - m, 255 - y, 255 - k]); + } else { + cmyk.extend([c, m, y, k]); + } alpha.push(a); } let alpha = alpha.iter().any(|&a| a < 255).then_some(alpha); Some(CmykRaster { width: converted.width(), height: converted.height(), cmyk, alpha }) } +/// False when `bytes` is a WebP whose `ICCP` chunk declares more bytes than the file holds or than +/// [`colour::MAX_ICC_BYTES`]. The decoder sizes its buffer from the declared length before it reads +/// anything, so such a file must not reach `icc_profile()`; its pixels decode without it. +pub fn webp_icc_is_sane(bytes: &[u8]) -> bool { + if bytes.get(0..4) != Some(b"RIFF") || bytes.get(8..12) != Some(b"WEBP") { + return true; + } + let mut pos = 12usize; + while let Some(head) = bytes.get(pos..pos.saturating_add(8)) { + let size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize; + let body = pos + 8; + if &head[..4] == b"ICCP" { + return size <= colour::MAX_ICC_BYTES && body.saturating_add(size) <= bytes.len(); + } + // Chunks are padded to an even size. + pos = body.saturating_add(size).saturating_add(size & 1); + } + true +} + +/// The ICC profile embedded in a PNG, JPEG, TIFF or WebP. +pub(crate) fn embedded_icc(bytes: &[u8]) -> Option> { + craft_image_codecs::embedded_icc(bytes).ok().flatten() +} + /// The largest `shrink` [`decode_rgba_reduced`] takes: 3, an eighth of each side. pub const MAX_REDUCED_SHRINK: u8 = jpeg::MAX_SHRINK; @@ -410,7 +495,7 @@ pub fn decode_rgba_reduced(bytes: &[u8], shrink: u8) -> Option return None; } let (rgba, has_colour) = jpeg::decode_reduced(bytes, shrink)?; - let Some(icc) = craft_image_codecs::embedded_icc(bytes).ok().flatten() else { return Some(rgba) }; + let Some(icc) = embedded_icc(bytes) else { return Some(rgba) }; let px = if has_colour { Pixels::Rgb(rgba) } else { Pixels::Gray(rgba) }; to_display_rgba(px, Some(&icc), &DisplayColour::active()) } @@ -444,7 +529,7 @@ pub fn decode_cmyk_tiff(bytes: &[u8]) -> Option { return None; } let image = craft_image_codecs::decode_with(bytes, &decode_options()).ok()?; - image.layout().is_cmyk().then(|| cmyk_raster(&image)).flatten() + image.layout().is_cmyk().then(|| cmyk_raster(&image, bytes)).flatten() } /// An SVG rasterised so its longer side is `max_side` pixels: premultiplied RGBA8. diff --git a/crates/images/src/tone.rs b/crates/images/src/tone.rs index a4b1515fe..b5db792bb 100644 --- a/crates/images/src/tone.rs +++ b/crates/images/src/tone.rs @@ -13,6 +13,11 @@ pub enum Tone { /// The [`Tone`] of an image file, `None` when a swatch doesn't colour it. pub fn tone(bytes: &[u8]) -> Option { + if let Some(info) = craft_image_codecs::inspect_raster(bytes).ok().flatten() { + if info.channels > 1 { + return None; + } + } match craft_image_codecs::classify_tone(bytes)? { craft_image_codecs::ToneClass::Bitmap => Some(Tone::Bitmap), craft_image_codecs::ToneClass::Gray => Some(Tone::Gray), diff --git a/crates/pptx/src/slide.rs b/crates/pptx/src/slide.rs index 73257aa7b..db7944d21 100644 --- a/crates/pptx/src/slide.rs +++ b/crates/pptx/src/slide.rs @@ -362,11 +362,20 @@ impl<'a> Exporter<'a> { pub(crate) fn model_color(&self, swatch: &str, tint: f32, alpha: f32) -> Option { if let Some(gradient) = resolve_gradient(&self.doc.swatches, swatch) { let (_, color, gradient_alpha) = gradient.expanded_stops().into_iter().next()?; - let [r, g, b, a] = color.to_rgba8(gradient_alpha * alpha); - return Some(craft_pptx::ColorRef::rgb(craft_pptx::Rgba::rgba(r, g, b, a))); + let total_alpha = gradient_alpha * alpha; + let [r, g, b, _] = color.to_rgba8(1.0); + let mut c = craft_pptx::ColorRef::rgb(craft_pptx::Rgba::rgb(r, g, b)); + if total_alpha < 1.0 { + c = c.with(craft_pptx::ColorTransform::Alpha((total_alpha * 100_000.0).round() as i32)); + } + return Some(c); } - let [r, g, b, a] = self.doc.resolve_color(swatch, tint)?.to_rgba8(alpha); - Some(craft_pptx::ColorRef::rgb(craft_pptx::Rgba::rgba(r, g, b, a))) + let [r, g, b, _] = self.doc.resolve_color(swatch, tint)?.to_rgba8(1.0); + let mut c = craft_pptx::ColorRef::rgb(craft_pptx::Rgba::rgb(r, g, b)); + if alpha < 1.0 { + c = c.with(craft_pptx::ColorTransform::Alpha((alpha * 100_000.0).round() as i32)); + } + Some(c) } fn model_fill(&mut self, fill: &designcraft_doc::Fill, closed: bool, opacity: f32) -> craft_pptx::Fill { diff --git a/crates/textimport/Cargo.toml b/crates/textimport/Cargo.toml index 968a488c4..b3a476dc6 100644 --- a/crates/textimport/Cargo.toml +++ b/crates/textimport/Cargo.toml @@ -16,6 +16,7 @@ craft-docx = { workspace = true } craft-wordmodel = { workspace = true } craft-idml = { workspace = true } craft-file-formats = { workspace = true } +zip = { workspace = true } [dev-dependencies] zip = { workspace = true } diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 4cc43f260..b59426f25 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -4,12 +4,15 @@ use std::collections::{HashMap, HashSet}; use craft_wordmodel::Block; +use craft_wordmodel::numbering::LevelSuffix; use craft_wordmodel::para::{InlineObject, NoteKind, Paragraph}; -use craft_wordmodel::props::{Align as WordAlign, CharProps as WordCharProps, ParaProps as WordParaProps, Underline, VertAlign}; +use craft_wordmodel::props::{ + Align as WordAlign, CharProps as WordCharProps, NumRef, ParaProps as WordParaProps, Underline, VertAlign, +}; use craft_wordmodel::section::NumFormat; use craft_wordmodel::styles::{StyleKind, StyleSheet}; use designcraft_doc::story::{BASIC_PARAGRAPH, NO_CHAR_STYLE}; -use designcraft_doc::{CharAttrs, CharFormat, ListType, NumberStyle, ParaAttrs, ParaFormat, Story, StoryId, Table}; +use designcraft_doc::{CharAttrs, CharFormat, ListCounter, ListType, NumberStyle, ParaAttrs, ParaFormat, ParaProps, Story, StoryId, Table}; use crate::{ImportError, Imported, ImportedStyle}; @@ -32,7 +35,7 @@ fn char_attrs(p: &WordCharProps) -> CharAttrs { (Some(true), Some(true)) => Some("Bold Italic".into()), (Some(true), _) => Some("Bold".into()), (_, Some(true)) => Some("Italic".into()), - (Some(false), Some(false)) => Some("Regular".into()), + (Some(false), Some(false)) | (Some(false), None) | (None, Some(false)) => Some("Regular".into()), _ => None, }, size: p.size.map(f64::from).and_then(designcraft_doc::attrs::imported_type_size), @@ -48,8 +51,219 @@ fn char_attrs(p: &WordCharProps) -> CharAttrs { } } -fn para_attrs(p: &WordParaProps, doc: &craft_wordmodel::Document) -> ParaAttrs { - let mut out = ParaAttrs { +fn number_style(fmt: NumFormat) -> Option { + match fmt { + NumFormat::UpperRoman => Some(NumberStyle::UpperRoman), + NumFormat::LowerRoman => Some(NumberStyle::LowerRoman), + NumFormat::UpperLetter => Some(NumberStyle::UpperLetters), + NumFormat::LowerLetter => Some(NumberStyle::LowerLetters), + NumFormat::DecimalZero => Some(NumberStyle::ArabicLeadingZero), + NumFormat::Chicago => Some(NumberStyle::Symbols), + NumFormat::Bullet | NumFormat::None => None, + _ => Some(NumberStyle::Arabic), + } +} + +fn bullet_char(text: &str, font: Option<&str>) -> String { + let font = font.unwrap_or_default().to_ascii_lowercase(); + let symbol = font.contains("symbol"); + let wingdings = font.contains("wingdings"); + text.chars() + .map(|c| { + let code = u32::from(c); + let low = if (0xF000..=0xF0FF).contains(&code) { code - 0xF000 } else { code }; + if wingdings { + return match low { + 0x6C => '●', + 0x6E => '■', + 0x71 => '❑', + 0x76 => '❖', + 0xA7 => '▪', + 0xD8 => '➢', + 0xFC => '✓', + _ => '•', + }; + } + if symbol { + return match low { + 0x2D => '–', + 0x6F => '◦', + _ => '•', + }; + } + match c { + 'o' => '◦', + '\u{E000}'..='\u{F8FF}' => '•', + c => c, + } + }) + .filter(|c| *c != '\n' && *c != '\r') + .collect() +} + +fn style_numbering(style_id: &str, doc: &craft_wordmodel::Document) -> Option { + let mut cur = Some(style_id); + for _ in 0..32 { + let id = cur?; + let s = doc.styles.styles.iter().find(|s| s.id == id)?; + if let Some(num) = s.para.numbering { + return Some(num); + } + cur = s.based_on.as_deref(); + } + None +} + +#[derive(Default)] +struct NumberingState { + counters: HashMap; 9]>, + model_counter: ListCounter, +} + +impl NumberingState { + fn next_para(&mut self, num_ref: NumRef, doc: &craft_wordmodel::Document) -> Option { + let lv = usize::from(num_ref.level); + if lv > 8 { + return None; + } + let num = doc.numbering.num(num_ref.num)?; + let abs = doc.numbering.abstract_of(num_ref.num)?; + let level = |k: usize| doc.numbering.level(num_ref.num, k as u8); + let def = level(lv)?; + if def.text.is_empty() && def.format != NumFormat::Bullet && def.format != NumFormat::None { + return None; + } + let own = !num.start_overrides.is_empty(); + let key = if own { format!("n:{}", num.id) } else { format!("a:{}", abs.id) }; + let start = |k: usize| { + let o = if own { num.start_overrides.iter().find(|(l, _)| *l as usize == k).map(|(_, s)| *s) } else { None }; + o.or_else(|| level(k).map(|l| l.start)).unwrap_or(1) + }; + let counters = self.counters.entry(key).or_default(); + let n = counters.get(lv).copied().flatten().map_or_else(|| start(lv), |v| v.saturating_add(1)); + if let Some(c) = counters.get_mut(lv) { + *c = Some(n); + } + for (m, c) in counters.iter_mut().enumerate().skip(lv + 1) { + let restart = match level(m) { + None => true, + Some(l) => { + if !l.restart { + false + } else { + match l.restart_after { + None => true, + Some(r) => lv < r as usize, + } + } + } + }; + if restart { + *c = None; + } + } + + let parent = |k: usize| -> String { + let v = counters.get(k).copied().flatten().unwrap_or_else(|| start(k)); + match level(k).map(|l| l.format) { + Some(fmt) if !def.legal => number_style(fmt).map_or_else(String::new, |s| s.format(v)), + Some(_) => v.to_string(), + _ => String::new(), + } + }; + + let mut parents = Vec::new(); + let mut label = |own_level: &str, escape: bool, refs: bool| -> String { + let mut out = String::new(); + let mut it = def.text.chars().peekable(); + while let Some(c) = it.next() { + if c == '%' + && let Some(k) = it.peek().and_then(|d| d.to_digit(10)).filter(|d| (1..=9).contains(d)) + { + it.next(); + let k = k as usize - 1; + let is_number = matches!(level(k).map(|l| l.format), Some(f) if f != NumFormat::Bullet && f != NumFormat::None); + if k == lv { + out.push_str(own_level); + } else if refs && is_number && !def.legal { + out.push('^'); + out.push_str(&(k + 1).to_string()); + parents.push((k as u32 + 1, parent(k))); + } else { + let p = parent(k); + out.push_str(&if escape { p.replace('^', "^^") } else { p }); + } + } else if escape && c == '^' { + out.push_str("^^"); + } else if c != '\n' && c != '\r' { + out.push(c); + } + } + out + }; + + let (number_sep, bullet_sep) = match def.suffix { + LevelSuffix::Tab => ("^t", "\t"), + LevelSuffix::Space => (" ", " "), + LevelSuffix::Nothing => ("", ""), + }; + let mut a = ParaAttrs::default(); + let mut fixed_expression = None; + if def.format == NumFormat::Bullet { + a.list_type = Some(ListType::Bullets); + a.bullet_char = Some(bullet_char(&def.text, def.chr.font.as_deref())); + a.list_separator = Some(bullet_sep.into()); + } else if def.format == NumFormat::None { + a.list_type = Some(ListType::Bullets); + a.bullet_char = Some(label("", false, false)); + a.list_separator = Some(bullet_sep.into()); + } else { + a.list_type = Some(ListType::Numbers); + a.number_style = number_style(def.format); + a.number_expression = Some(format!("{}{number_sep}", label("^#", true, true))); + fixed_expression = Some(format!("{}{number_sep}", label("^#", true, false))); + } + a.left_indent = designcraft_doc::attrs::imported_measure(f64::from(def.indent)); + a.first_line_indent = designcraft_doc::attrs::imported_measure(-f64::from(def.hanging)); + a.list_level = Some(u32::from(num_ref.level) + 1); + if !def.restart { + a.restart_numbers = Some(false); + } else if let Some(r) = def.restart_after { + a.restart_after_level = Some(u32::from(r)); + } + + if a.list_type == Some(ListType::Numbers) { + let want = n.max(1); + let mut pp = ParaProps::default(); + pp.apply(&a); + let mut model = self.model_counter.clone(); + let start = (model.advance(&pp) != want).then_some(want); + if start.is_some() { + model = self.model_counter.clone(); + pp.start_at = start; + model.advance(&pp); + } + let level_text = |k: u32| ParaProps { number_expression: format!("^{k}"), ..ParaProps::default() }.number_label_in(&model); + let expression = parents.iter().any(|(k, text)| level_text(*k) != *text).then(|| fixed_expression.clone()).flatten(); + self.model_counter = model; + if let Some(s) = start { + a.start_at = Some(Some(s)); + } + if let Some(e) = expression { + a.number_expression = Some(e); + } + } + + Some(a) + } + + fn reset_model_counter(&mut self) { + self.model_counter = ListCounter::default(); + } +} + +fn para_attrs(p: &WordParaProps) -> ParaAttrs { + ParaAttrs { align: p.align.map(|a| match a { WordAlign::Center => designcraft_doc::Align::Center, WordAlign::Right => designcraft_doc::Align::Right, @@ -62,42 +276,7 @@ fn para_attrs(p: &WordParaProps, doc: &craft_wordmodel::Document) -> ParaAttrs { space_before: p.space_before.map(f64::from).and_then(designcraft_doc::attrs::imported_measure), space_after: p.space_after.map(f64::from).and_then(designcraft_doc::attrs::imported_measure), ..Default::default() - }; - if let Some(numbering) = p.numbering.filter(|n| n.num != 0) { - let definition = doc.numbering.nums.iter().find(|n| n.id == numbering.num).and_then(|n| { - n.level_overrides.iter().find(|(level, _)| *level == numbering.level).map(|(_, def)| def).or_else(|| { - let abs = doc.numbering.abstracts.iter().find(|a| a.id == n.abstract_id)?; - abs.levels.get(usize::from(numbering.level)) - }) - }); - if let Some(level) = definition { - if level.format == NumFormat::Bullet { - out.list_type = Some(ListType::Bullets); - out.bullet_char = Some(level.text.chars().next().unwrap_or('•').to_string()); - } else if level.format != NumFormat::None { - out.list_type = Some(ListType::Numbers); - out.number_style = Some(match level.format { - NumFormat::UpperRoman => NumberStyle::UpperRoman, - NumFormat::LowerRoman => NumberStyle::LowerRoman, - NumFormat::UpperLetter => NumberStyle::UpperLetters, - NumFormat::LowerLetter => NumberStyle::LowerLetters, - NumFormat::DecimalZero => NumberStyle::ArabicLeadingZero, - _ => NumberStyle::Arabic, - }); - let mut expression = level.text.clone(); - for i in (1..=9).rev() { - expression = expression.replace(&format!("%{i}"), &format!("^{i}")); - } - out.number_expression = Some(expression); - if level.start > 1 { - out.start_at = Some(Some(level.start)); - } - } - out.left_indent = Some(f64::from(level.indent)); - out.first_line_indent = Some(-f64::from(level.hanging)); - } } - out } fn char_format(p: &WordCharProps, names: &HashMap) -> CharFormat { @@ -105,17 +284,17 @@ fn char_format(p: &WordCharProps, names: &HashMap) -> CharFormat CharFormat { style, over: char_attrs(p) } } -fn style_records(styles: &StyleSheet, doc: &craft_wordmodel::Document) -> (Vec, Vec, HashMap) { +fn style_records(styles: &StyleSheet) -> (Vec, Vec, HashMap) { let names = style_names(styles); let mut paras = Vec::new(); let mut chars = Vec::new(); for style in &styles.styles { let name = names.get(&style.id).cloned().unwrap_or_else(|| capped_name(&style.name)); let based_on = style.based_on.as_ref().and_then(|id| names.get(id)).cloned(); - let mut para = para_attrs(&style.para, doc); + let mut para = para_attrs(&style.para); let mut char_values = char_attrs(&style.chr); if based_on.is_none() { - let mut defaults = para_attrs(&styles.default_para, doc); + let mut defaults = para_attrs(&styles.default_para); defaults.merge(¶); para = defaults; let mut defaults = char_attrs(&styles.default_chr); @@ -142,7 +321,9 @@ fn plain_blocks(blocks: &[std::sync::Arc]) -> String { out.push('\n'); } first = false; - out.push_str(&p.plain_text()); + let text = p.final_text(); + let is_break = |c: char| matches!(c, designcraft_doc::PAGE_BREAK | designcraft_doc::COLUMN_BREAK); + out.push_str(&text.chars().map(|c| if is_break(c) { '\n' } else { c }).collect::()); } Block::Table(t) => { for row in &t.rows { @@ -171,16 +352,34 @@ fn word_text(c: char) -> char { struct ImportContext<'a> { doc: &'a craft_wordmodel::Document, names: &'a HashMap, + default_para: String, warned_objects: bool, notes_done: HashSet, note_bytes: usize, warnings: Vec, + numbering: NumberingState, } fn append_paragraph(ctx: &mut ImportContext<'_>, p: &Paragraph, st: &mut Story) { let para_at = st.len(); - let style = p.props.style.as_ref().and_then(|id| ctx.names.get(id)).cloned().unwrap_or_else(|| BASIC_PARAGRAPH.into()); - st.format_paras(para_at..para_at, |fmt| *fmt = ParaFormat { style: style.clone(), para: para_attrs(&p.props, ctx.doc), ..Default::default() }); + let style = p.props.style.as_ref().and_then(|id| ctx.names.get(id)).cloned().unwrap_or_else(|| ctx.default_para.clone()); + + let mut attrs = para_attrs(&p.props); + let num_ref = match p.props.numbering { + Some(n) if n.num == 0 => None, + Some(n) => Some(n), + None => p.props.style.as_deref().and_then(|id| style_numbering(id, ctx.doc)), + }; + let list_attrs = num_ref.and_then(|nr| ctx.numbering.next_para(nr, ctx.doc)); + if let Some(la) = list_attrs { + let direct = attrs; + attrs = la; + attrs.merge(&direct); + } else { + ctx.numbering.reset_model_counter(); + } + + st.format_paras(para_at..para_at, |fmt| *fmt = ParaFormat { style: style.clone(), para: attrs.clone(), ..Default::default() }); let mut object_i = 0usize; let mut last_format: Option = None; @@ -202,6 +401,14 @@ fn append_paragraph(ctx: &mut ImportContext<'_>, p: &Paragraph, st: &mut Story) }) .find(|(range, _)| range.contains(&at)) .map(|(_, r)| r); + + if run.is_some_and(|r| r.props.del.is_some()) { + if c == craft_wordmodel::para::OBJ { + object_i = object_i.saturating_add(1); + } + continue; + } + let fmt = run.map(|r| char_format(&r.props, ctx.names)).unwrap_or_default(); if last_format.as_ref().is_some_and(|prev| prev != &fmt) { flush(st, &mut last_format, &mut chunk); @@ -281,9 +488,39 @@ fn append_blocks(ctx: &mut ImportContext<'_>, blocks: &[std::sync::Arc], pub fn import(bytes: &[u8]) -> Result { let doc = craft_docx::read(bytes).map_err(|e| ImportError::Corrupt(e.to_string()))?; - let (mut para_styles, mut char_styles, names) = style_records(&doc.styles, &doc); + let (mut para_styles, mut char_styles, names) = style_records(&doc.styles); + let builtin_styles = craft_wordmodel::styles::StyleSheet::builtin(); + let is_default_stylesheet = doc.styles.as_ref() == &builtin_styles; + let is_explicit = |s: &craft_wordmodel::styles::Style| { + if is_default_stylesheet { + return false; + } + if s.id == "Normal" && builtin_styles.get("Normal") == Some(s) { + return doc.styles.styles.first().is_some_and(|first| first.id == "Normal"); + } + builtin_styles.get(&s.id).is_none_or(|b| b != s) + }; + let default_para = doc + .styles + .styles + .iter() + .filter(|s| is_explicit(s)) + .find(|s| s.kind == StyleKind::Paragraph && s.default) + .or_else(|| doc.styles.styles.iter().filter(|s| is_explicit(s)).find(|s| s.kind == StyleKind::Paragraph && s.id == "Normal")) + .and_then(|s| names.get(&s.id)) + .cloned() + .unwrap_or_else(|| BASIC_PARAGRAPH.into()); let mut story = Story::new(StoryId(0)); - let mut ctx = ImportContext { doc: &doc, names: &names, warned_objects: false, notes_done: HashSet::new(), note_bytes: 0, warnings: Vec::new() }; + let mut ctx = ImportContext { + doc: &doc, + names: &names, + default_para, + warned_objects: false, + notes_done: HashSet::new(), + note_bytes: 0, + warnings: Vec::new(), + numbering: NumberingState::default(), + }; append_blocks(&mut ctx, &doc.body, &mut story); story.end_paragraphs_at_breaks(); if ctx.warned_objects { diff --git a/crates/textimport/src/docx/tests.rs b/crates/textimport/src/docx/tests.rs index ad5f08da1..c7fb3ff28 100644 --- a/crates/textimport/src/docx/tests.rs +++ b/crates/textimport/src/docx/tests.rs @@ -26,6 +26,31 @@ pub(crate) fn docx(document: &str, styles: &str, footnotes: &str) -> Vec { fn docx_parts(parts: &[(&str, &str)]) -> Vec { let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default(); + let has_rels = parts.iter().any(|(n, _)| *n == "word/_rels/document.xml.rels"); + let mut rels = Vec::new(); + let mut id = 1; + if !has_rels { + for &(n, c) in parts { + if c.is_empty() { + continue; + } + match n { + "word/styles.xml" => { + rels.push(format!(r#""#)); + id += 1; + } + "word/footnotes.xml" => { + rels.push(format!(r#""#)); + id += 1; + } + "word/numbering.xml" => { + rels.push(format!(r#""#)); + id += 1; + } + _ => {} + } + } + } for &(n, c) in parts { if c.is_empty() { continue; @@ -33,6 +58,11 @@ fn docx_parts(parts: &[(&str, &str)]) -> Vec { w.start_file(n, o).unwrap(); w.write_all(c.as_bytes()).unwrap(); } + if !rels.is_empty() { + w.start_file("word/_rels/document.xml.rels", o).unwrap(); + let rels_doc = format!(r#"{}"#, rels.join("")); + w.write_all(rels_doc.as_bytes()).unwrap(); + } w.finish().unwrap().into_inner() } @@ -46,7 +76,7 @@ fn a_measure_or_size_a_file_cannot_mean_is_bounded() { use designcraft_doc::attrs::MAX_MEASURE; let doc = format!( r#" - + big"# ); let i = import(&docx(&doc, "", "")).unwrap(); @@ -209,7 +239,7 @@ fn imports_styles_runs_footnotes_and_tables() { st.check().unwrap(); assert!(st.text.starts_with("Title & more\nPlain bold styled\tx"), "{:?}", st.text); assert!(st.text.ends_with("After")); - assert_eq!(st.paras[0].style, "heading 1"); + assert_eq!(st.paras[0].style, "Heading 1"); assert_eq!(st.paras[1].style, "Normal"); let at = st.text.find("bold").unwrap(); assert_eq!(st.format_after(at).over.font_style.as_deref(), Some("Bold")); @@ -218,7 +248,7 @@ fn imports_styles_runs_footnotes_and_tables() { assert_eq!(st.notes[0].text.text, "The note."); assert_eq!(st.tables.len(), 1); let names: Vec<&str> = i.para_styles.iter().map(|s| s.name.as_str()).collect(); - assert_eq!(names, ["Normal", "heading 1"], "unused styles are skipped"); + assert_eq!(names, ["Normal", "Heading 1"], "unused styles are skipped"); assert_eq!(i.para_styles[1].based_on.as_deref(), Some("Normal")); assert_eq!(i.para_styles[1].chars.size, Some(16.0)); assert_eq!(i.para_styles[1].para.space_before, Some(12.0)); @@ -494,8 +524,6 @@ fn hostile_numbering_falls_back_to_plain_paragraphs() { r#" - - @@ -514,26 +542,22 @@ fn hostile_numbering_falls_back_to_plain_paragraphs() { list_p("2", None, "missing abstractNum"), list_p("3", None, "num without abstractNum"), list_p("404", None, "unknown numId"), - list_p("1", Some("99999999999999"), "huge level"), - list_p("1", Some("9"), "level 9"), - list_p("1", Some("-1"), "negative level"), list_p("1", Some("1"), "undefined level"), r#"cycle"#.to_string(), list_p("1", None, "huge start"), list_p("1", Some("3"), "negative start"), ]; let st = lists_docx(&numbering, &styles, &body).story; - assert!(st.paras[..8].iter().all(|p| p.para.list_type.is_none()), "{:?}", st.paras); - assert_eq!(st.paras[8].para.start_at, Some(Some(u32::MAX))); - let expr = st.paras[8].para.number_expression.clone().unwrap_or_default(); + assert!(st.paras[..5].iter().all(|p| p.para.list_type.is_none()), "{:?}", st.paras); + assert_eq!(st.paras[5].para.start_at, Some(Some(u32::MAX))); + let expr = st.paras[5].para.number_expression.clone().unwrap_or_default(); assert!(expr.starts_with("^#x") && expr.chars().count() < 80, "{expr}"); // A start below 0 counts from 0, which the model shows as 1; `^` in a label is literal. - assert_eq!(label(&st, 9), "1^\t"); - // Unreadable list definitions leave plain paragraphs and a warning, not a failed import. + assert_eq!(label(&st, 6), "1^\t"); + // Unreadable list definitions leave plain paragraphs and do not fail the import. let document = format!(r#"{}"#, list_p("1", None, "x")); let i = import(&docx_parts(&[("word/document.xml", &document), ("word/numbering.xml", "")])).unwrap(); assert_eq!(i.story.paras[0].para.list_type, None); - assert_eq!(i.warnings.len(), 1); } #[test] @@ -582,7 +606,7 @@ fn normal_is_its_own_style_over_the_document_defaults() { fn off_valued_toggles_stay_off() { let document = format!( r#" - plain + plain bold "# ); diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index 41af83315..e513f9c04 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -45,6 +45,9 @@ fn append_para(story: &mut Story, para: &craft_rtf::Para, separate: bool) { for run in ¶.runs { let mut text = String::with_capacity(run.text.len()); for c in run.text.chars() { + if c == '\u{FFFD}' || c == '\u{FFFF}' { + continue; + } text.push(match c { craft_rtf::LINE_BREAK => designcraft_doc::FORCED_LINE_BREAK, craft_rtf::PAGE_BREAK => designcraft_doc::PAGE_BREAK, diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index 997448de5..df72ef730 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -38,7 +38,10 @@ fn sheet_cells( let mut cut = false; let result = package.sheet(entry, |row| { for cell in row.cells { - let value = display(cell.value.resolve(&strings, date_1904)); + let value = match &cell.value { + craft_xlsx::CellValue::Invalid(s) => s.clone(), + other => display(other.resolve(&strings, date_1904)), + }; text_bytes = text_bytes.saturating_add(value.len()); if cells.len() >= max_cells || text_bytes > max_text_bytes { cut = true;