fix(idml,images,pptx,textimport): update adapters and tests for craft-libs cutover

This commit is contained in:
Dylan M. Taylor committed 2026-11-14 03:35:09 +00:00
1 parent 32274e843a
commit 382fc27a5b
14 files changed
+483 -81

No files matched your search

Generated
+1
View File
@@ -2173,6 +2173,7 @@ dependencies = [
"image",
"jpeg-decoder",
"log",
"quick-xml 0.41.0",
"skrifa 0.47.0",
"tiff",
]
+4 -2
View File
@@ -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());
}
+21 -1
View File
@@ -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<Vec<f64>, _> = s.unwrap_or("").split_whitespace().take(7).map(str::parse::<f64>).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());
+8 -3
View File
@@ -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 };
+7 -1
View File
@@ -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();
+1
View File
@@ -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 }
+91 -6
View File
@@ -263,8 +263,46 @@ fn add_face(db: &mut Arc<usvg::fontdb::Database>, face: Arc<designcraft_fonts::F
ids.into_iter().find(|id| db.face(*id).is_some_and(|f| f.index == index))
}
/// Require UTF-8, bounded XML events, balanced tags and depth at most 64; reject internal DTD subsets while accepting ordinary external doctypes.
fn svg_preflight(bytes: &[u8]) -> 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<usvg::Tree> {
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<usvg::Tree> {
};
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<image::RgbaImage> {
}
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<CmykRaster> {
/// 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<CmykRaster> {
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<Vec<u8>> {
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<image::RgbaImage>
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<CmykRaster> {
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.
+5
View File
@@ -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<Tone> {
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),
+13 -4
View File
@@ -362,11 +362,20 @@ impl<'a> Exporter<'a> {
pub(crate) fn model_color(&self, swatch: &str, tint: f32, alpha: f32) -> Option<craft_pptx::ColorRef> {
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 {
+1
View File
@@ -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 }
+285 -48
View File
@@ -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<NumberStyle> {
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<NumRef> {
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<String, [Option<u32>; 9]>,
model_counter: ListCounter,
}
impl NumberingState {
fn next_para(&mut self, num_ref: NumRef, doc: &craft_wordmodel::Document) -> Option<ParaAttrs> {
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<String, String>) -> CharFormat {
@@ -105,17 +284,17 @@ fn char_format(p: &WordCharProps, names: &HashMap<String, String>) -> CharFormat
CharFormat { style, over: char_attrs(p) }
}
fn style_records(styles: &StyleSheet, doc: &craft_wordmodel::Document) -> (Vec<ImportedStyle>, Vec<ImportedStyle>, HashMap<String, String>) {
fn style_records(styles: &StyleSheet) -> (Vec<ImportedStyle>, Vec<ImportedStyle>, HashMap<String, String>) {
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);
para = defaults;
let mut defaults = char_attrs(&styles.default_chr);
@@ -142,7 +321,9 @@ fn plain_blocks(blocks: &[std::sync::Arc<Block>]) -> 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::<String>());
}
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<String, String>,
default_para: String,
warned_objects: bool,
notes_done: HashSet<u32>,
note_bytes: usize,
warnings: Vec<String>,
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<CharFormat> = 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<Block>],
pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
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 {
+39 -15
View File
@@ -26,6 +26,31 @@ pub(crate) fn docx(document: &str, styles: &str, footnotes: &str) -> Vec<u8> {
fn docx_parts(parts: &[(&str, &str)]) -> Vec<u8> {
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#"<Relationship Id="rId{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#));
id += 1;
}
"word/footnotes.xml" => {
rels.push(format!(r#"<Relationship Id="rId{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/>"#));
id += 1;
}
"word/numbering.xml" => {
rels.push(format!(r#"<Relationship Id="rId{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>"#));
id += 1;
}
_ => {}
}
}
}
for &(n, c) in parts {
if c.is_empty() {
continue;
@@ -33,6 +58,11 @@ fn docx_parts(parts: &[(&str, &str)]) -> Vec<u8> {
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#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{}</Relationships>"#, 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#"<w:document {W}><w:body><w:p><w:pPr>
<w:spacing w:before="1e300" w:after="-1e300"/><w:ind w:left="1e300" w:firstLine="-1e300"/>
<w:spacing w:before="1e30" w:after="-1e30"/><w:ind w:left="1e30" w:firstLine="-1e30"/>
</w:pPr><w:r><w:rPr><w:sz w:val="1e300"/></w:rPr><w:t>big</w:t></w:r></w:p></w:body></w:document>"#
);
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#"<w:numbering {W}>
<w:abstractNum w:abstractNumId="1">
<w:lvl w:ilvl="0"><w:start w:val="99999999999999"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1{long}%9%"/></w:lvl>
<w:lvl w:ilvl="99999999999999"><w:numFmt w:val="decimal"/></w:lvl>
<w:lvl w:ilvl="-1"><w:numFmt w:val="decimal"/></w:lvl>
<w:lvl w:ilvl="3"><w:start w:val="-5"/><w:numFmt w:val="ordinalText"/><w:lvlText w:val="%4^"/><w:lvlRestart w:val="99"/></w:lvl>
</w:abstractNum>
<w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num>
@@ -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#"<w:p><w:pPr><w:pStyle w:val="A"/><w:numPr><w:ilvl w:val="0"/></w:numPr></w:pPr><w:r><w:t>cycle</w:t></w:r></w:p>"#.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#"<w:document {W}><w:body>{}</w:body></w:document>"#, list_p("1", None, "x"));
let i = import(&docx_parts(&[("word/document.xml", &document), ("word/numbering.xml", "<w:numbering></w:num>")])).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#"<w:document {W}><w:body><w:p>
<w:r><w:rPr><w:b w:val="off"/><w:i w:val="0"/><w:strike w:val="off"/><w:u w:val="off"/><w:caps w:val="off"/></w:rPr><w:t>plain</w:t></w:r>
<w:r><w:rPr><w:b w:val="off"/><w:i w:val="0"/><w:strike w:val="off"/><w:u w:val="none"/><w:caps w:val="off"/></w:rPr><w:t>plain</w:t></w:r>
<w:r><w:rPr><w:b w:val="on"/></w:rPr><w:t>bold</w:t></w:r>
</w:p></w:body></w:document>"#
);
+3
View File
@@ -45,6 +45,9 @@ fn append_para(story: &mut Story, para: &craft_rtf::Para, separate: bool) {
for run in &para.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,
+4 -1
View File
@@ -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;