refactor(docx): split read.rs into read/

This commit is contained in:
aiongg
2026-10-26 21:16:49 +00:00
parent b8d3ec94b9
commit 0a4f5b8e84
5 changed files with 1125 additions and 1108 deletions
+9 -1108
View File
File diff suppressed because it is too large Load Diff
+358
View File
@@ -0,0 +1,358 @@
use super::*;
impl Reader<'_> {
pub(super) fn read_settings(&mut self, path: &str) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
if let Some(b) = self.pkg.get(path).filter(|b| crate::write::settings_has_unowned(b)) {
self.doc.passthrough.insert(crate::package::SETTINGS_ORIG.into(), Arc::new(b.to_vec()));
}
let rels = self.pkg.rels(path);
let s = &mut self.doc.settings;
for k in root.els() {
match k.name.as_str() {
// §17.15.1.6: the template, by a relationship (usually external) of the settings part.
"w:attachedTemplate" => {
s.attached_template = k
.attr("r:id")
.and_then(|id| rels.by_id(id))
.map(|r| r.target.trim().to_string())
.filter(|t| !t.is_empty() && t.len() <= MAX_TEMPLATE_PATH);
}
"w:linkStyles" => s.link_styles = on_off(k),
"w:removePersonalInformation" => s.remove_personal_info = on_off(k),
"w:removeDateAndTime" => s.remove_date_time = on_off(k),
"w:compat" => {
s.allow_space_of_same_style_in_table = k.child("w:allowSpaceOfSameStyleInTable").is_some_and(on_off);
// Only Word's own setting: another `w:uri` names a different application's.
let mode = k
.children("w:compatSetting")
.find(|c| {
c.attr("w:name") == Some("compatibilityMode")
&& c.attr("w:uri").is_none_or(|u| u == "http://schemas.microsoft.com/office/word")
})
.and_then(|c| c.attr("w:val"));
if let Some(m) = mode.and_then(u32_of) {
s.compat_mode = m.clamp(11, 99);
}
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
s.do_not_expand_shift_return = k.children("w:doNotExpandShiftReturn").any(on_off);
}
"w:trackRevisions" => s.track_changes = on_off(k),
"w:doNotTrackMoves" => s.track_moves = !on_off(k),
"w:defaultTabStop" => {
if let Some(v) = tw(k, "w:val").filter(|v| *v > 0.0) {
s.default_tab = v.clamp(1.0, 1584.0);
}
}
"w:evenAndOddHeaders" => s.even_odd_headers = on_off(k),
"w:drawingGridHorizontalSpacing" | "w:drawingGridVerticalSpacing" => {
if let Some(v) = tw(k, "w:val").filter(|v| *v > 0.0) {
let v = v.clamp(0.5, 1584.0);
if k.name == "w:drawingGridHorizontalSpacing" { s.grid_h = v } else { s.grid_v = v }
}
}
"m:mathPr" => s.math = Some(math::read_math_pr(k)),
"w:mirrorMargins" => s.mirror_margins = on_off(k),
"w:autoHyphenation" => s.auto_hyphenation = on_off(k),
"w:doNotHyphenateCaps" => s.hyphenate_caps = !on_off(k),
"w:hyphenationZone" => {
if let Some(v) = tw(k, "w:val").filter(|v| v.is_finite() && *v >= 0.0) {
s.hyphenation_zone = v.min(1584.0);
}
}
"w:consecutiveHyphenLimit" => {
if let Some(v) = k.attr("w:val").and_then(u32_of) {
s.consecutive_hyphen_limit = v.min(32_767);
}
}
"w:footnotePr" => {
if let Some(f) = k.child_val("w:numFmt") {
s.footnote_format = NumFormat::from_ooxml(f);
}
s.footnote_pr = props::note_pr(k, false);
}
"w:endnotePr" => {
if let Some(f) = k.child_val("w:numFmt") {
s.endnote_format = NumFormat::from_ooxml(f);
}
s.endnote_pr = props::note_pr(k, false);
}
"w:documentProtection" => read_protection(k, s),
"w:zoom" => {
let percent = k.attr("w:percent").and_then(|v| u32_of(v.trim_end_matches('%'))).unwrap_or(100).clamp(10, 500);
let fit = match k.attr("w:val") {
Some("fullPage") => Some(ZoomFit::OnePage),
Some("bestFit") => Some(ZoomFit::PageWidth),
Some("textFit") => Some(ZoomFit::TextWidth),
_ => None,
};
s.zoom = Some(Zoom { percent, fit }).filter(|z| z.percent != 100 || z.fit.is_some());
}
_ => {}
}
}
Ok(())
}
pub(super) fn read_notes(&mut self, path: &str, foot: bool) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
let rels = self.pkg.rels(path);
let tag = if foot { "w:footnote" } else { "w:endnote" };
for n in root.children(tag) {
match n.attr("w:type") {
None | Some("normal") => {}
Some(t @ ("separator" | "continuationSeparator" | "continuationNotice")) => {
self.read_note_sep(n, &rels, foot, t);
continue;
}
Some(_) => continue,
}
let Some(fid) = n.attr("w:id").and_then(int) else { continue };
if self.doc.parts.len() >= MAX_PARTS {
break;
}
// Reserve the id first: the note's own reference mark points at it.
let (pk, kind) = if foot { (PartKind::Footnote, NoteKind::Footnote) } else { (PartKind::Endnote, NoteKind::Endnote) };
let id = self.doc.add_part(pk, Blocks::new());
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
self.current_note = Some((kind, id));
self.read_blocks(&mut sc, n, &rels, &mut blocks, 0);
self.flush_pending(&mut sc, &mut blocks);
self.current_note = None;
if let Some(p) = self.doc.parts.get_mut(&id)
&& !blocks.is_empty()
{
p.blocks = blocks;
}
if foot {
self.footnotes.insert(fid, id);
} else {
self.endnotes.insert(fid, id);
}
}
Ok(())
}
/// A document's own separator, continuation separator or continuation notice (`kind`, the
/// note's `w:type`) for its footnotes or endnotes. The first of each kind counts.
fn read_note_sep(&mut self, n: &El, rels: &Rels, foot: bool, kind: &str) {
let seps = if foot { &self.doc.footnote_separators } else { &self.doc.endnote_separators };
let taken = match kind {
"separator" => seps.separator.is_some(),
"continuationSeparator" => seps.continuation_separator.is_some(),
_ => seps.continuation_notice.is_some(),
};
if taken {
return;
}
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
self.in_note_sep = true;
self.read_blocks(&mut sc, n, rels, &mut blocks, 0);
self.flush_pending(&mut sc, &mut blocks);
self.in_note_sep = false;
if blocks.is_empty() {
blocks.push(para_block(Paragraph::new()));
}
let seps = if foot { &mut self.doc.footnote_separators } else { &mut self.doc.endnote_separators };
let slot = match kind {
"separator" => &mut seps.separator,
"continuationSeparator" => &mut seps.continuation_separator,
_ => &mut seps.continuation_notice,
};
*slot = Some(blocks);
}
/// The extended properties a save carries over ([`crate::package::APP_KEPT_PROPS`]).
pub(super) fn read_app(&mut self, path: &str) {
let Ok(Some(root)) = self.xml(path) else { return };
let mut kept = String::new();
for e in root.els() {
let name = e.local();
let value = e.text();
if crate::package::APP_KEPT_PROPS.contains(&name) && value.len() <= 2048 && !value.contains(['\t', '\n']) {
kept.push_str(&format!("{name}\t{value}\n"));
}
}
if !kept.is_empty() {
self.doc.passthrough.insert(crate::package::APP_KEPT.into(), Arc::new(kept.into_bytes()));
}
}
pub(super) fn read_comments(&mut self, path: &str, ext: Option<&str>, ids: Option<&str>, cex: Option<&str>) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
let rels = self.pkg.rels(path);
let mut by_para: HashMap<String, u32> = HashMap::new();
let mut next_id = 0u32;
let mut entries: BTreeMap<u32, Comment> = BTreeMap::new();
// Ids given before this part, looked up rather than scanned for every generated id: a
// scan per comment made many non-numeric ids quadratic (#3692). Ids given here are in
// `entries`.
let given: HashSet<u32> = self.comment_map.values().copied().collect();
for c in root.children("w:comment") {
if self.doc.parts.len() >= MAX_PARTS {
break;
}
let file_id = c.attr("w:id").unwrap_or("").to_string();
if self.comment_map.contains_key(&file_id) {
continue;
}
let id = match file_id.parse::<u32>() {
Ok(v) if !entries.contains_key(&v) => v,
_ => {
while entries.contains_key(&next_id) || given.contains(&next_id) {
next_id += 1;
}
next_id
}
};
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
self.read_blocks(&mut sc, c, &rels, &mut blocks, 0);
self.flush_pending(&mut sc, &mut blocks);
let part = self.doc.add_part(PartKind::Comment, blocks);
if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) {
by_para.insert(pid.to_string(), id);
}
entries.insert(
id,
Comment {
author: c.attr("w:author").unwrap_or("").to_string(),
initials: c.attr("w:initials").unwrap_or("").to_string(),
date: c.attr("w:date").unwrap_or("").to_string(),
parent: None,
resolved: false,
part,
..Default::default()
},
);
self.comment_map.insert(file_id, id);
}
if let Some(ext) = ext
&& let Ok(Some(x)) = self.xml(ext)
{
for e in x.els().filter(|e| e.local() == "commentEx") {
let Some(cid) = e.attr("w15:paraId").and_then(|p| by_para.get(p)).copied() else { continue };
let parent = e.attr("w15:paraIdParent").and_then(|p| by_para.get(p)).copied().filter(|p| *p != cid);
let done = e.attr("w15:done").is_some_and(|v| v == "1" || v == "true");
if let Some(c) = entries.get_mut(&cid) {
c.parent = parent;
c.resolved = done;
}
}
}
// Durable ids (by paragraph id) and UTC dates (by durable id), which the writer
// carries over (`write_comment_ids`). Their namespaces (w16cid, w16cex) have no canonical
// prefix here, so attributes are found by local name.
let local = |e: &crate::xml::El, name: &str| -> Option<String> {
e.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some(name)).map(|(_, v)| v.clone())
};
let mut by_durable: HashMap<String, u32> = HashMap::new();
if let Some(ids) = ids
&& let Ok(Some(x)) = self.xml(ids)
{
for e in x.els().filter(|e| e.local() == "commentId") {
let Some(cid) = local(e, "paraId").and_then(|p| by_para.get(&p).copied()) else { continue };
let Some(durable) = local(e, "durableId").filter(|d| crate::write::durable_id_ok(d)) else { continue };
if let Some(c) = entries.get_mut(&cid) {
c.durable_id = Some(durable.to_ascii_uppercase());
by_durable.insert(durable.to_ascii_uppercase(), cid);
}
}
}
if let Some(cex) = cex
&& let Ok(Some(x)) = self.xml(cex)
{
for e in x.els().filter(|e| e.local() == "commentExtensible") {
let Some(cid) = local(e, "durableId").and_then(|d| by_durable.get(&d.to_ascii_uppercase()).copied()) else { continue };
let date = local(e, "dateUtc").filter(|d| d.len() <= 40 && d.chars().all(|c| c.is_ascii_alphanumeric() || ":-.+".contains(c)));
if let (Some(c), Some(date)) = (entries.get_mut(&cid), date) {
c.date_utc = Some(date);
}
}
}
self.doc.comments = entries;
Ok(())
}
/// Sources from a `b:Sources` custom XML part (other custom XML is left alone); at most
/// [`MAX_SOURCES`] in all, and the document's citation style from `StyleName`.
pub(super) fn read_bibliography(&mut self, path: &str, seen: &mut Seen) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
if root.local() != "Sources" {
return Ok(());
}
let room = MAX_SOURCES.saturating_sub(self.doc.sources.len());
let bib = crate::bibliography::parse(&root, seen, room);
if self.doc.settings.bib_style.is_none() {
self.doc.settings.bib_style = bib.style;
}
self.doc.sources.extend(bib.sources);
Ok(())
}
pub(super) fn read_core(&mut self, path: &str) {
let Ok(Some(root)) = self.xml(path) else { return };
let c = &mut self.doc.core;
for k in root.els() {
let t = k.text().trim().to_string();
match k.name.as_str() {
"dc:title" => c.title = t,
"dc:subject" => c.subject = t,
"dc:creator" => c.creator = t,
"cp:keywords" => c.keywords = t,
"dc:description" => c.description = t,
"cp:lastModifiedBy" => c.last_modified_by = t,
"cp:revision" => c.revision = u32_of(&t).unwrap_or(0),
"dcterms:created" => c.created = t,
"dcterms:modified" => c.modified = t,
"cp:category" => c.category = t,
_ => {}
}
}
}
}
/// The password hash attributes of `w:documentProtection` that are kept (ECMA-376 §17.15.1.29:
/// the current `algorithmName`/`hashValue`/`saltValue`/`spinCount`, and the older transitional
/// `crypt*`/`hash`/`salt` set).
pub(crate) const PROTECTION_HASH_ATTRS: [&str; 16] = [
"w:algorithmName",
"w:hashValue",
"w:saltValue",
"w:spinCount",
"w:cryptProviderType",
"w:cryptAlgorithmClass",
"w:cryptAlgorithmType",
"w:cryptAlgorithmSid",
"w:cryptSpinCount",
"w:cryptProvider",
"w:algIdExt",
"w:algIdExtSource",
"w:cryptProviderTypeExt",
"w:cryptProviderTypeExtSource",
"w:hash",
"w:salt",
];
/// Restrict Editing (`w:documentProtection`, §17.15.1.29): the editing restriction, whether
/// formatting is limited to unlocked styles, and the password hash. Only enforced protection is
/// kept; a hostile attribute value can't be longer than a hash needs.
fn read_protection(k: &El, s: &mut wordcraft_doc::Settings) {
let on = |v: &str| !matches!(v, "0" | "false" | "off");
if !k.attr("w:enforcement").is_some_and(on) {
return;
}
let edit = k.attr("w:edit").filter(|e| matches!(*e, "readOnly" | "comments" | "trackedChanges" | "forms"));
let formatting = k.attr("w:formatting").is_some_and(on);
if edit.is_none() && !formatting {
return;
}
s.protection = Some(edit.unwrap_or("none").to_string());
s.protect_formatting = formatting;
s.protect_hash = PROTECTION_HASH_ATTRS
.iter()
.filter_map(|n| k.attr(n).filter(|v| !v.is_empty() && v.len() <= 1024).map(|v| (n.to_string(), v.to_string())))
.collect();
}
+253
View File
@@ -0,0 +1,253 @@
use super::*;
impl Reader<'_> {
/// Keep the parts the VBA project at `project` relates to, with their relationship types and
/// content types (see [`VBA_RELATED`]). Parts outside `word/`, relationship parts, the project
/// itself and missing targets are skipped (logged); at most [`MAX_VBA_RELATED`] are kept.
pub(super) fn read_vba_related(&mut self, project: &str) {
let pkg = self.pkg;
let types = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg));
let mut manifest = String::new();
let mut kept = 0usize;
for rel in self.pkg.rels(project).list.iter().filter(|r| !r.external) {
// Part names compare case-insensitively (OPC): `/WORD/x` names the part the writer
// stores as `word/x`.
let target = rel.target.as_str();
let path = match (target.get(..5), target.get(5..)) {
(Some(dir), Some(rest)) if dir.eq_ignore_ascii_case("word/") => format!("word/{rest}"),
_ => target.to_string(),
};
let path = path.as_str();
let ct = types.of(path).unwrap_or("application/octet-stream");
let usable = path.strip_prefix("word/").is_some_and(|t| !t.is_empty())
&& !path.to_ascii_lowercase().ends_with(".rels")
&& !path.eq_ignore_ascii_case(project)
&& !path.eq_ignore_ascii_case(VBA_PROJECT_PART)
&& ![rel.kind.as_str(), path, ct].iter().any(|f| f.is_empty() || f.contains(['\t', '\r', '\n']));
if !usable {
log::warn!("docx: not carrying VBA-related part {path:?}");
continue;
}
let Some(bytes) = self.pkg.get_shared(path) else {
log::warn!("docx: VBA-related part {path} is missing; keeping the project without it");
continue;
};
if kept >= MAX_VBA_RELATED {
log::warn!("docx: more than {MAX_VBA_RELATED} VBA-related parts; the rest are dropped");
break;
}
kept += 1;
manifest.push_str(&format!("{}\t{path}\t{ct}\n", rel.kind));
self.doc.passthrough.entry(path.to_string()).or_insert_with(|| bytes.clone());
}
if !manifest.is_empty() {
self.doc.passthrough.insert(VBA_RELATED.into(), Arc::new(manifest.into_bytes()));
}
}
/// The fonts the font table classes as roman (`w:font/w:family w:val="roman"`).
pub(super) fn read_font_families(&mut self, path: &str) {
let Ok(Some(table)) = self.xml(path) else { return };
self.doc.settings.roman_fonts = table
.children("w:font")
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
.filter_map(|f| f.attr("w:name"))
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
.take(MAX_TABLE_FONTS)
.map(str::to_string)
.collect();
}
pub(super) fn xml(&self, path: &str) -> Result<Option<El>, DocxError> {
self.pkg.xml(path)
}
pub fn revision(&mut self, kind: RevisionKind, e: &El) -> u32 {
self.revision_named(kind, e, None)
}
pub(super) fn revision_named(&mut self, kind: RevisionKind, e: &El, move_name: Option<String>) -> u32 {
let author = e.attr("w:author").unwrap_or("").chars().take(256).collect::<String>();
let date = e.attr("w:date").unwrap_or("").chars().take(64).collect::<String>();
let k = match kind {
RevisionKind::Insert => 0,
RevisionKind::Delete => 1,
RevisionKind::Format => 2,
};
let key = (k, author.clone(), date.clone(), move_name.clone());
if let Some(i) = self.revs.get(&key) {
return *i;
}
let i = self.doc.revisions.len() as u32;
self.doc.revisions.push(Revision { kind, author, date, move_name });
self.revs.insert(key, i);
i
}
/// Media key for an image relationship (loads the bytes once).
pub fn media_for(&mut self, rels: &Rels, id: &str) -> Option<String> {
let rel = rels.by_id(id)?;
if rel.external {
return None;
}
if let Some(k) = self.media_by_path.get(&rel.target) {
return Some(k.clone());
}
let bytes = self.pkg.get_shared(&rel.target)?;
let base: String =
rel.target.rsplit('/').next().unwrap_or("image").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).collect();
let base = if base.is_empty() || base.starts_with('.') { format!("image{base}") } else { base };
let mut key = base.clone();
let mut n = 1;
while self.doc.media.contains_key(&key) {
n += 1;
key = match base.rsplit_once('.') {
Some((stem, ext)) => format!("{stem}_{n}.{ext}"),
None => format!("{base}_{n}"),
};
}
self.doc.media.insert(key.clone(), bytes.clone());
self.media_by_path.insert(rel.target.clone(), key.clone());
Some(key)
}
/// A chart or diagram part, parsed once however often it's referenced (`None` when unreadable).
pub(super) fn graphic_part(&mut self, path: &str) -> Option<Arc<El>> {
if let Some(p) = self.graphic_parts.get(path) {
return p.clone();
}
let p = self.xml(path).ok().flatten().map(Arc::new);
self.graphic_parts.insert(path.to_string(), p.clone());
p
}
pub fn load_header_footer(&mut self, path: &str, footer: bool) -> Option<u32> {
if let Some(id) = self.hf_by_path.get(path) {
return Some(*id);
}
if self.doc.parts.len() >= MAX_PARTS || self.hf_depth >= MAX_HF_DEPTH {
return None;
}
// Reserve the id first so a (malformed) self-reference can't recurse.
let kind = if footer { PartKind::Footer } else { PartKind::Header };
let id = self.doc.add_part(kind, Blocks::new());
self.hf_by_path.insert(path.to_string(), id);
let root = match self.xml(path) {
Ok(Some(r)) => r,
_ => return Some(id),
};
let rels = self.pkg.rels(path);
let mut sc = StoryCtx::default();
sc.in_hf = true;
let mut blocks = Blocks::new();
self.hf_depth += 1;
self.read_blocks(&mut sc, &root, &rels, &mut blocks, 0);
self.hf_depth = self.hf_depth.saturating_sub(1);
self.flush_pending(&mut sc, &mut blocks);
if let Some(p) = self.doc.parts.get_mut(&id)
&& !blocks.is_empty()
{
p.blocks = blocks;
}
Some(id)
}
pub(super) fn read_theme(&mut self, path: &str) {
let Ok(Some(t)) = self.xml(path) else { return };
if let Some(n) = t.attr("name").filter(|n| !n.is_empty()) {
self.doc.settings.theme_name = n.to_string();
}
let Some(el) = t.child("a:themeElements") else { return };
if let Some(cs) = el.child("a:clrScheme") {
let names = ["dk1", "lt1", "dk2", "lt2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink"];
let mut colors = self.doc.settings.theme_colors.clone();
colors.resize(12, Rgb::BLACK);
for (i, n) in names.iter().enumerate() {
let Some(c) = cs.child(&format!("a:{n}")) else { continue };
let rgb = c
.child("a:srgbClr")
.and_then(|x| x.attr("val"))
.and_then(Rgb::parse)
.or_else(|| c.child("a:sysClr").and_then(|x| x.attr("lastClr")).and_then(Rgb::parse));
if let (Some(rgb), Some(slot)) = (rgb, colors.get_mut(i)) {
*slot = rgb;
}
}
self.pc.theme_colors = colors.clone();
self.doc.settings.theme_colors = colors;
}
self.theme_fills = shape_style::ThemeFills::read(el.child("a:fmtScheme"));
if let Some(fs) = el.child("a:fontScheme") {
let face =
|n: &str| fs.child(n).and_then(|f| f.child("a:latin")).and_then(|l| l.attr("typeface")).filter(|t| !t.is_empty()).map(str::to_string);
if let Some(f) = face("a:majorFont") {
self.doc.settings.major_font = f;
}
if let Some(f) = face("a:minorFont") {
self.doc.settings.minor_font = f;
}
let cs = |n: &str| fs.child(n).and_then(|f| f.child("a:cs")).and_then(|c| c.attr("typeface")).unwrap_or_default().to_string();
self.pc.major_cs = cs("a:majorFont");
self.pc.minor_cs = cs("a:minorFont");
// Per-script fonts (`a:font script="Arab"`); Office's themes list about 30.
let scripts = |n: &str| -> Vec<(String, String)> {
let Some(f) = fs.child(n) else { return Vec::new() };
f.els()
.filter(|e| e.name == "a:font")
.filter_map(|e| Some((e.attr("script")?, e.attr("typeface")?)))
.filter(|(s, t)| s.len() <= 8 && !t.is_empty() && t.len() < 256)
.take(256)
.map(|(s, t)| (s.to_string(), t.to_string()))
.collect()
};
self.pc.major_scripts = scripts("a:majorFont");
self.pc.minor_scripts = scripts("a:minorFont");
}
self.pc.major_font = self.doc.settings.major_font.clone();
self.pc.minor_font = self.doc.settings.minor_font.clone();
self.pc.theme = self.doc.settings.theme_colors.clone();
}
/// `w:settings/w:clrSchemeMapping`: which scheme colour text1, background1… stand for. Also
/// `w:themeFontLang/@w:bidi`, the language whose script picks the `…Bidi` theme fonts.
pub(super) fn read_color_mapping(&mut self, path: &str) {
let Ok(Some(root)) = self.xml(path) else { return };
self.pc.bidi_script = root.child("w:themeFontLang").and_then(|l| l.attr("w:bidi")).filter(|v| !v.trim().is_empty()).map(props::lang_script);
let Some(m) = root.child("w:clrSchemeMapping") else { return };
let names = [
"dark1",
"light1",
"dark2",
"light2",
"accent1",
"accent2",
"accent3",
"accent4",
"accent5",
"accent6",
"hyperlink",
"followedHyperlink",
];
let attrs = [
"w:t1",
"w:bg1",
"w:t2",
"w:bg2",
"w:accent1",
"w:accent2",
"w:accent3",
"w:accent4",
"w:accent5",
"w:accent6",
"w:hyperlink",
"w:followedHyperlink",
];
let mut map = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
for (slot, a) in map.iter_mut().zip(attrs) {
if let Some(i) = m.attr(a).and_then(|v| names.iter().position(|n| *n == v)) {
*slot = i;
}
}
self.pc.color_map = Some(map);
}
}
+337
View File
@@ -0,0 +1,337 @@
use super::*;
/// Built-in style names Word stores in lower case, and how the UI shows them.
pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[
("caption", "Caption"),
("header", "Header"),
("footer", "Footer"),
("footnote text", "Footnote Text"),
("footnote reference", "Footnote Reference"),
("endnote text", "Endnote Text"),
("endnote reference", "Endnote Reference"),
("annotation text", "Annotation Text"),
("annotation reference", "Annotation Reference"),
("annotation subject", "Annotation Subject"),
("table of figures", "Table of Figures"),
("normal indent", "Normal Indent"),
("line number", "Line Number"),
("page number", "Page Number"),
("index heading", "Index Heading"),
("envelope address", "Envelope Address"),
("envelope return", "Envelope Return"),
("toa heading", "TOA Heading"),
("table of authorities", "Table of Authorities"),
];
/// File style name → display name.
pub(crate) fn ui_style_name(n: &str) -> String {
if let Some((_, ui)) = LOWER_NAMES.iter().find(|(f, _)| *f == n) {
return (*ui).to_string();
}
for (pre, ui) in [("heading ", "Heading "), ("toc ", "TOC "), ("index ", "Index ")] {
if let Some(d) = n.strip_prefix(pre)
&& d.len() == 1
&& d.chars().all(|c| c.is_ascii_digit())
{
return format!("{ui}{d}");
}
}
n.to_string()
}
impl Reader<'_> {
pub(super) fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
/// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB).
const MAX_LATENT_STYLES_XML: usize = 1 << 20;
let Some(root) = self.xml(path)? else { return Ok(()) };
// The default paragraph style must be called `Normal` in our model.
let has_normal = root.children("w:style").any(|s| s.attr("w:styleId") == Some("Normal"));
if !has_normal
&& let Some(def) = root
.children("w:style")
.find(|s| s.attr("w:type") == Some("paragraph") && s.attr("w:default").is_some_and(|v| v == "1" || v == "true"))
&& let Some(id) = def.attr("w:styleId")
{
self.pc.alias.insert(id.to_string(), "Normal".into());
}
// Word in other languages names the TOC styles' ids in that language (`toc 1` is `Sumrio1`
// in Portuguese): they take the engine's ids, which Update Table and the writer's TOC
// recognise the entries by (#3889). Not when the file or an earlier style has that id.
let mut taken: Vec<String> = root.children("w:style").take(10_000).filter_map(|s| s.attr("w:styleId")).map(str::to_string).collect();
for s in root.children("w:style").take(10_000).filter(|s| s.attr("w:type").is_none_or(|t| t == "paragraph")) {
let (Some(id), Some(name)) = (s.attr("w:styleId"), s.child_val("w:name")) else { continue };
let canonical = match name.trim().to_ascii_lowercase().as_str() {
"toc heading" => "TOCHeading".to_string(),
n => match n.strip_prefix("toc ").and_then(|d| d.parse::<u8>().ok()).filter(|d| (1..=9).contains(d)) {
Some(d) => format!("TOC{d}"),
None => continue,
},
};
if !taken.contains(&canonical) && !self.pc.alias.contains_key(id) {
taken.push(canonical.clone());
self.pc.alias.insert(id.to_string(), canonical);
}
}
let mut sheet = StyleSheet::empty();
sheet.default_para_present = root.child("w:docDefaults").and_then(|dd| dd.child("w:pPrDefault")).is_some();
if let Some(dd) = root.child("w:docDefaults") {
if let Some(r) = dd.child("w:rPrDefault").and_then(|d| d.child("w:rPr")) {
sheet.default_chr = self.pc.rpr(r);
}
if let Some(p) = dd.child("w:pPrDefault").and_then(|d| d.child("w:pPr")) {
sheet.default_para = self.pc.ppr(p).0;
// Word makes no paragraph a text frame from the document defaults: the sizeless
// top-left `w:framePr` Apple Pages writes there would put every paragraph, the
// headers' and footers' included, at the page's corner (#2360).
sheet.default_para.frame = None;
}
}
if sheet.default_chr.size.is_none() {
sheet.default_chr.size = Some(10.0);
}
// Built-in styles' behaviour (gallery, priority, visibility), written back as is.
sheet.latent_styles = root.child("w:latentStyles").map(|l| l.to_xml()).filter(|x| x.len() <= MAX_LATENT_STYLES_XML);
for s in root.children("w:style").take(10_000) {
let Some(raw_id) = s.attr("w:styleId").filter(|i| !i.is_empty()) else { continue };
let id = self.pc.style_id(raw_id);
let kind = match s.attr("w:type").unwrap_or("paragraph") {
"character" => StyleKind::Character,
"table" => StyleKind::Table,
"numbering" => StyleKind::Numbering,
_ => StyleKind::Paragraph,
};
let name = s.child_val("w:name").map(ui_style_name).unwrap_or_else(|| id.clone());
let r = |n: &str| s.child_val(n).filter(|v| !v.is_empty()).map(|v| self.pc.style_id(v));
let mut st = Style {
id: id.clone(),
name,
kind,
based_on: r("w:basedOn").filter(|b| *b != id),
next: r("w:next"),
linked: r("w:link"),
priority: s.child_val("w:uiPriority").and_then(u32_of),
quick: flag(s, "w:qFormat").unwrap_or(false),
hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false),
builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"),
locked: flag(s, "w:locked").unwrap_or(false),
// Only where the writer couldn't tell from the id (it flags the English ids).
default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true")
&& !matches!(
(kind, id.as_str()),
(StyleKind::Paragraph, "Normal")
| (StyleKind::Character, "DefaultParagraphFont")
| (StyleKind::Table, "TableNormal")
| (StyleKind::Numbering, "NoList")
),
..Default::default()
};
if let Some(p) = s.child("w:pPr") {
st.para = self.pc.ppr(p).0;
}
if let Some(rp) = s.child("w:rPr") {
st.chr = self.pc.rpr(rp);
}
if kind == StyleKind::Table {
let parts = self.table_style_parts(s, &mut st);
st.table = (parts != TableStyleParts::default()).then_some(parts);
}
// A style defined twice (some generators write their own Heading 1 after a
// placeholder one) is the last definition, whole: Word drops the earlier one rather
// than merging the two (observed with synthetic documents).
match sheet.styles.iter_mut().find(|s| s.id == id) {
Some(earlier) => *earlier = st,
None => sheet.styles.push(st),
}
}
// Styles that give only a list level take the list from the style they are based on
// (Heading 3: level 3 of Heading 2's outline list).
let fixed: Vec<Option<wordcraft_doc::props::NumRef>> = sheet
.styles
.iter()
.map(|s| match s.para.numbering {
Some(n) if n.num == props::LEVEL_ONLY => {
let base = s.based_on.as_deref().and_then(|b| props::chain_list(&sheet, b));
base.map(|num| wordcraft_doc::props::NumRef { num, level: n.level })
}
other => other,
})
.collect();
for (s, n) in sheet.styles.iter_mut().zip(fixed) {
s.para.numbering = n;
}
if sheet.default_para.numbering.is_some_and(|n| n.num == props::LEVEL_ONLY) {
sheet.default_para.numbering = None;
}
// A producer that never writes `w:qFormat` (LibreOffice) still gets Word's usual gallery.
if !sheet.styles.iter().any(|s| s.quick) {
sheet.add_default_gallery();
}
self.doc.styles = sheet;
Ok(())
}
/// A table style's table-level formatting and conditional formats. The `wholeTable` region
/// applies to every cell, so its paragraph and run properties go to the style's own (`st`).
fn table_style_parts(&self, s: &El, st: &mut Style) -> TableStyleParts {
let mut t = TableStyleParts::default();
if let Some(tp) = s.child("w:tblPr") {
if let Some(b) = tp.child("w:tblBorders") {
t.borders = Some(props::borders(b));
}
t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e));
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).map(|n| n.min(1000));
t.col_band_size = tp.child_val("w:tblStyleColBandSize").and_then(u32_of).map(|n| n.min(1000));
}
for c in s.children("w:tblStylePr").take(64) {
let Some(part) = c.attr("w:type").and_then(TablePart::from_ooxml) else { continue };
let tc = c.child("w:tcPr");
let ppr = c.child("w:pPr").map(|p| self.pc.ppr(p).0);
let f = PartFormat {
chr: c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(),
fill: tc.and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)),
borders: tc.and_then(|p| p.child("w:tcBorders")).map(props::borders),
align: ppr.as_ref().and_then(|p| p.align),
v_align: tc.and_then(|p| p.child_val("w:vAlign")).map(|v| match v {
"center" => VAlign::Center,
"bottom" => VAlign::Bottom,
_ => VAlign::Top,
}),
};
if part == TablePart::WholeTable {
// The whole table's paragraph and run properties are the style's own; its
// borders the table borders.
if let Some(p) = ppr {
st.para.overlay(&p);
}
st.chr.overlay(&f.chr);
if f.fill.is_some() {
t.fill = f.fill;
}
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
t.borders = Some(props::borders(b));
}
if f.v_align.is_some() {
t.regions.insert(part, PartFormat { v_align: f.v_align, ..PartFormat::default() });
}
} else {
t.set_part(part, f);
}
}
t
}
pub(super) fn read_numbering(&mut self, path: &str) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
let mut abstracts = Vec::new();
let mut links: Vec<(u32, String)> = Vec::new();
for a in root.children("w:abstractNum").take(10_000) {
let Some(id) = a.attr("w:abstractNumId").and_then(u32_of) else { continue };
let mut levels: Vec<Level> = (0..9).map(default_level).collect();
for l in a.children("w:lvl") {
let i = l.attr("w:ilvl").and_then(u32_of).unwrap_or(0) as usize;
if let Some(slot) = levels.get_mut(i) {
*slot = self.level(l, i);
}
}
if let Some(link) = a.child_val("w:numStyleLink") {
links.push((id, link.to_string()));
}
let hex = |v: &str| v.len() == 8 && v.bytes().all(|b| b.is_ascii_hexdigit());
abstracts.push(AbstractNum {
id,
name: a.child_val("w:name").map(str::to_string),
levels,
nsid: a.child_val("w:nsid").filter(|v| hex(v)).map(str::to_ascii_uppercase),
tmpl: a.child_val("w:tmpl").filter(|v| hex(v)).map(str::to_ascii_uppercase),
multi_level_type: a
.child_val("w:multiLevelType")
// `multilevel` is what the writer derives for the nine levels read here.
.filter(|v| matches!(*v, "singleLevel" | "hybridMultilevel"))
.map(str::to_string),
});
}
let mut nums = Vec::new();
for n in root.children("w:num").take(10_000) {
let (Some(id), Some(abs)) = (n.attr("w:numId").and_then(u32_of), n.child_val("w:abstractNumId").and_then(u32_of)) else { continue };
let (mut start_overrides, mut level_overrides) = (Vec::new(), Vec::new());
for o in n.children("w:lvlOverride").take(9) {
let lvl = o.attr("w:ilvl").and_then(u32_of).unwrap_or(0).min(8) as u8;
if let Some(s) = o.child_val("w:startOverride").and_then(u32_of) {
start_overrides.push((lvl, s));
} else if let Some(s) = o.child("w:lvl").and_then(|l| l.child_val("w:start")).and_then(u32_of) {
start_overrides.push((lvl, s));
}
// A whole level definition replaces the abstract list's for this list.
if let Some(l) = o.child("w:lvl") {
level_overrides.push((lvl, self.level(l, lvl as usize)));
}
}
nums.push(Num { id, abstract_id: abs, start_overrides, level_overrides });
}
// Abstracts that only link to a numbering style take that style's list levels.
for (aid, style) in links {
let num = self.doc.styles.get(&self.pc.style_id(&style)).and_then(|s| s.para.numbering).map(|n| n.num);
let src = num.and_then(|n| nums.iter().find(|x: &&Num| x.id == n)).map(|x| x.abstract_id);
let levels = src.filter(|s| *s != aid).and_then(|s| abstracts.iter().find(|a: &&AbstractNum| a.id == s)).map(|a| a.levels.clone());
if let (Some(levels), Some(a)) = (levels, abstracts.iter_mut().find(|a| a.id == aid)) {
a.levels = levels;
}
}
self.doc.numbering.abstracts = abstracts;
self.doc.numbering.nums = nums;
Ok(())
}
fn level(&self, l: &El, i: usize) -> Level {
let mut lv = default_level(i);
if let Some(s) = l.child_val("w:start").and_then(u32_of) {
lv.start = s;
}
if let Some(f) = l.child_val("w:numFmt") {
lv.format = NumFormat::from_ooxml(f);
}
lv.text = l.child_val("w:lvlText").map(|t| t.chars().take(64).collect()).unwrap_or_default();
if let Some(j) = l.child_val("w:lvlJc") {
lv.align = props::align(j);
}
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
lv.indent = v;
}
if let Some(h) = tw(ind, "w:hanging") {
lv.hanging = h;
} else if let Some(f) = tw(ind, "w:firstLine") {
lv.hanging = -f;
}
}
if let Some(r) = l.child("w:rPr") {
lv.chr = self.pc.rpr(r);
}
lv.suffix = match l.child_val("w:suff") {
Some("space") => LevelSuffix::Space,
Some("nothing") => LevelSuffix::Nothing,
_ => LevelSuffix::Tab,
};
lv.legal = flag(l, "w:isLgl").unwrap_or(false);
let restart = l.child_val("w:lvlRestart").and_then(int);
lv.restart = restart.is_none_or(|v| v != 0);
lv.restart_after = restart.filter(|v| (1..=9).contains(v)).map(|v| v as u8);
// The tab stop after the number ("num" tab in the level's paragraph properties).
lv.tab = l
.child("w:pPr")
.and_then(|p| p.child("w:tabs"))
.and_then(|t| t.children("w:tab").find(|t| t.attr("w:val") == Some("num")))
.and_then(|t| tw(t, "w:pos"))
.filter(|v| v.is_finite())
.map(|v| v.clamp(-1584.0, 1584.0));
lv.style = l.child_val("w:pStyle").filter(|s| !s.is_empty()).map(|s| self.pc.style_id(s));
lv
}
}
/// A level as the file defines it before its own elements: no indents (a level without `w:ind`
/// leaves the label at the paragraph's left edge, as Word shows it), no text.
fn default_level(_i: usize) -> Level {
Level { indent: 0.0, hanging: 0.0, text: String::new(), ..Level::default() }
}
+168
View File
@@ -0,0 +1,168 @@
use super::*;
/// Word draws a table of contents' entry links without the Hyperlink character style (no blue,
/// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run
/// in a hyperlink inside a TOC field's result is plain; one outside a hyperlink, another style,
/// direct formatting, or a hyperlink in another field keep their look). The style is dropped from
/// those runs so they look the same here; the links stay. The style is found by its built-in
/// name: localized Word and older files give it another id (`Hipervnculo`, `Hyperlnk`).
pub(super) fn unstyle_toc_links(doc: &mut Document) {
let ids: HashSet<String> = doc
.styles
.styles
.iter()
.filter(|s| s.kind == StyleKind::Character && s.name.eq_ignore_ascii_case("Hyperlink"))
.map(|s| s.id.clone())
.chain(["Hyperlink".to_string()])
.collect();
for f in doc.field_ranges(wordcraft_doc::StoryRef::Body) {
if is_toc(&f.instr) {
let _ = doc.format_range(&f.start, &f.end, &|c| {
if c.link.is_some() && c.style.as_deref().is_some_and(|s| ids.contains(s)) {
c.style = None;
}
});
}
}
}
/// Word's Table of Contents content control belongs to the TOC: the writer puts it back around
/// the TOC heading and its entries (`toc_span`), which References › Update Table rebuilds. It is
/// handed to the TOC only when the writer would put it back around the same paragraphs; any other
/// is kept as read, or saving would drop it (#2427).
pub(super) fn toc_control_to_toc(body: &mut Blocks) {
let span = {
let events = control_events(body);
let (pairs, _) = pair_events(&events);
pairs.iter().find_map(|&(o, c, depth)| {
let (ControlEvent::Open { block: s, off: Some(0), control }, ControlEvent::Close { block: e, off: Some(off) }) =
(events.get(o)?, events.get(c)?)
else {
return None;
};
let toc = matches!(&control.kind, ControlKind::Gallery { list: false, gallery, .. } if gallery == "Table of Contents");
let at_end = body.get(*e)?.as_para()?.len() == off.checked_add(OBJ.len_utf8())?;
(depth == 0 && toc && at_end).then_some((*s, *e, *off))
})
};
let Some((s, e, off)) = span else { return };
let mut toc = body.clone();
// The end first: both markers may be in the same paragraph.
if let Some(Block::Para(p)) = toc.get_mut(e).map(Arc::make_mut) {
let _ = p.delete(off, p.len());
}
if let Some(Block::Para(p)) = toc.get_mut(s).map(Arc::make_mut) {
let _ = p.delete(0, OBJ.len_utf8());
}
toc_field_to_heading(&mut toc);
if crate::write::written_toc_span(&toc) == Some((s, e)) {
*body = toc;
return;
}
// TOCs the writer rebuilds in the same paragraphs once Update Table has turned them into the
// engine's TOC (`range_toc_to_field`) are handed over as the range read, as without the control:
// - Word's layout, the field's end alone in a paragraph after the entries (it goes with them);
// - ours with a heading that isn't a `TOCHeading`, written empty and the field opening in the
// first entry. Kept in the control, that paragraph wouldn't be empty and each save and update
// would add a blank line above the entries (#2182).
let mut engine = toc.clone();
let mut last = e;
// The entry before Word's paragraph holding nothing but the field's end, and that end's format.
let end_only = e.checked_sub(1).filter(|&l| l > s).and_then(|l| {
let p = engine.get(e)?.as_para()?;
let only = p.section.is_none() && p.len() == OBJ.len_utf8() && matches!(p.object_at(0), Some(InlineObject::FieldEnd));
only.then(|| (l, p.props_of_char(0).clone()))
});
if let Some((l, props)) = end_only {
engine.remove(e);
last = l;
if let Some(Block::Para(p)) = engine.get_mut(l).map(Arc::make_mut) {
let len = p.len();
let _ = p.insert_object(len, InlineObject::FieldEnd, &props);
}
}
// An empty paragraph opening the control takes the field. It may end a section, as above
// entries in columns (#3889).
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
field_to_heading(&mut engine, s + 1);
} else {
toc_field_to_heading(&mut engine);
}
if crate::write::written_toc_span(&engine) == Some((s, last)) {
*body = toc;
}
}
/// Normalize only a complete TOC range that the engine's heading/entry representation can
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
pub(super) fn toc_field_to_heading(body: &mut Blocks) {
for i in 1..body.len() {
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
field_to_heading(body, i);
}
}
}
/// Moves the TOC field that opens paragraph `i` (a complete range over the entries, or an empty
/// field) to the end of paragraph `i - 1`, the engine's TOC heading, unless that one has a TOC
/// field already.
fn field_to_heading(body: &mut Blocks, i: usize) {
let has_toc = |o: &InlineObject| matches!(o, InlineObject::Field { instr, .. } if is_toc(instr));
if i == 0 || body.get(i - 1).and_then(|b| b.as_para()).is_none_or(|h| h.objects.iter().any(has_toc)) {
return;
}
let Some((field, props, end)) = body.get(i).and_then(|b| b.as_para()).and_then(|p| {
let (field, end) = match p.object_at(0) {
Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, dirty: *dirty, code: false, form: String::new() },
Some(toc_range_end(body, i)?),
),
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
_ => return None,
};
Some((field, p.props_of_char(0).clone(), end))
}) else {
return;
};
// Delete the end first: the two markers may occupy the same paragraph.
if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
let off = p.len().saturating_sub(OBJ.len_utf8());
let _ = p.delete(off, p.len());
}
if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
let _ = p.delete(0, OBJ.len_utf8());
}
if let Some(Block::Para(h)) = body.get_mut(i - 1).map(Arc::make_mut) {
let end = h.len();
let _ = h.insert_object(end, field, &props);
}
}
pub(super) fn is_toc(instr: &str) -> bool {
instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
}
/// The actual matching end must also be the end the styled writer would choose: at the end
/// of the last contiguous TOC entry, with no section break or trailing content outside it.
fn toc_range_end(body: &Blocks, start: usize) -> Option<usize> {
let entry =
|p: &wordcraft_doc::Paragraph| p.section.is_none() && p.props.style.as_deref().is_some_and(|s| s.starts_with("TOC") && s != "TOCHeading");
let mut depth = 0_usize;
for (i, b) in body.iter().enumerate().skip(start) {
let p = b.as_para().filter(|p| entry(p))?;
for (off, o) in p.object_offsets().into_iter().zip(&p.objects) {
match o {
InlineObject::FieldStart { .. } => depth = depth.checked_add(1)?,
InlineObject::FieldEnd => {
depth = depth.checked_sub(1)?;
if depth == 0 {
let following_entry = body.get(i + 1).and_then(|b| b.as_para()).is_some_and(entry);
return (off.checked_add(OBJ.len_utf8()) == Some(p.len()) && !following_entry).then_some(i);
}
}
_ => {}
}
}
}
None
}