mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:29:51 -06:00
refactor(docx): split read.rs into read/
This commit is contained in:
+9
-1108
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user