mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
fix(idml,images,pptx,textimport): update adapters and tests for craft-libs cutover
This commit is contained in:
1 parent
32274e843a
commit
382fc27a5b
14 files changed
+483
-81
No files matched your search
Generated
+1
@@ -2173,6 +2173,7 @@ dependencies = [
|
||||
"image",
|
||||
"jpeg-decoder",
|
||||
"log",
|
||||
"quick-xml 0.41.0",
|
||||
"skrifa 0.47.0",
|
||||
"tiff",
|
||||
]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 = 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 {
|
||||
|
||||
@@ -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>"#
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user