mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 11:57:36 -06:00
Shapes and connectors that take their fill and outline from the theme (wps:style a:fillRef/a:lnRef) now show; scheme, preset, system, scRGB and HSL colours resolve. Callouts and flowchart boxes draw as the nearest shape. Legacy VML lines (with arrowheads), rectangles and ovals are read. A DrawingML line runs top-left to bottom-right, as the preset does. Rebuilt on main, which now has connectors, arrowheads and canvases (#416, #344): those parts of the earlier branch were dropped in favour of main's. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
1140 lines
49 KiB
Rust
1140 lines
49 KiB
Rust
//! DOCX → [`Document`].
|
||
|
||
pub(crate) mod chart;
|
||
pub(crate) mod diagram;
|
||
mod drawing_color;
|
||
mod embed;
|
||
mod freeform;
|
||
mod math;
|
||
mod props;
|
||
mod sdt;
|
||
mod shape_style;
|
||
mod story;
|
||
mod wordart;
|
||
|
||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||
use std::sync::Arc;
|
||
|
||
use wordcraft_doc::graphic::{Graphic, GraphicKind};
|
||
use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num};
|
||
use wordcraft_doc::para::NoteKind;
|
||
use wordcraft_doc::para::OBJ;
|
||
use wordcraft_doc::props::Rgb;
|
||
use wordcraft_doc::section::NumFormat;
|
||
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet, TableStyleParts};
|
||
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block};
|
||
|
||
use crate::DocxError;
|
||
use crate::package::{ContentTypes, EMBEDDED_PARTS, EmbeddedManifest, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt};
|
||
use crate::units::{flag, int, on_off, tw, u32_of};
|
||
use crate::xml::El;
|
||
pub(crate) use props::PropCtx;
|
||
use story::StoryCtx;
|
||
|
||
/// Most parts (headers, notes, comments, text boxes) we create from one file.
|
||
const MAX_PARTS: usize = 50_000;
|
||
/// Most graphic work (items plus path segments, see `story::graphic_work`) built for one file's
|
||
/// charts and diagrams; later ones are left empty.
|
||
const MAX_GRAPHIC_WORK: usize = 2_000_000;
|
||
/// Longest attached-template path we keep (bytes); a longer one is not a real path.
|
||
const MAX_TEMPLATE_PATH: usize = 32 * 1024;
|
||
|
||
pub(crate) struct Reader<'p> {
|
||
pkg: &'p Package,
|
||
pub doc: Document,
|
||
pub pc: PropCtx,
|
||
revs: HashMap<(u8, String, String, Option<String>), u32>,
|
||
/// Move ranges (`w:moveFromRangeStart` …) seen so far.
|
||
pub moves: MoveCtx,
|
||
media_by_path: HashMap<String, String>,
|
||
hf_by_path: HashMap<String, u32>,
|
||
pub footnotes: HashMap<i64, u32>,
|
||
pub endnotes: HashMap<i64, u32>,
|
||
/// The note being read (kind, part id): its `w:footnoteRef` / `w:endnoteRef` mark is a
|
||
/// reference to itself.
|
||
current_note: Option<(NoteKind, u32)>,
|
||
/// Reading a footnote or endnote separator or continuation notice (where the `w:separator`
|
||
/// and `w:continuationSeparator` run elements belong).
|
||
in_note_sep: bool,
|
||
pub comment_map: HashMap<String, u32>,
|
||
pub comments_ended: HashSet<u32>,
|
||
pub bookmarks: HashMap<String, String>,
|
||
/// Restrict Editing exception ranges (`w:permStart`): the file's id → ours.
|
||
pub perms: HashMap<String, u32>,
|
||
/// Chart and diagram parts by path, parsed once.
|
||
graphic_parts: HashMap<String, Option<Arc<El>>>,
|
||
/// Charts and diagrams by (kind, part path, width bits, height bits), built once.
|
||
graphics: HashMap<(GraphicKind, String, u32, u32), Arc<Graphic>>,
|
||
/// Graphic work left before charts and diagrams are left empty (see [`MAX_GRAPHIC_WORK`]).
|
||
graphic_budget: usize,
|
||
/// Drawing ids connectors are glued to (`a:stCxn`, `a:endCxn`), and whether any shape's id
|
||
/// was kept while reading (those not glued to are dropped at the end).
|
||
pub glued: HashSet<u32>,
|
||
pub shape_ids: bool,
|
||
/// The main document part's name.
|
||
main: String,
|
||
/// The package's content types, read when first needed.
|
||
content_types: Option<ContentTypes>,
|
||
/// Parts kept for charts, diagrams and OLE objects (see `embed`).
|
||
embedded: EmbeddedManifest,
|
||
/// Linked text boxes as read: (`wps:txbx/@id` or `wps:linkedTxbx/@id`, `seq` (0 for the box
|
||
/// holding the text), text box part). See [`link_text_boxes`].
|
||
pub txbx_links: Vec<(u32, u32, u32)>,
|
||
}
|
||
|
||
/// The package path that internal relationship `id` points at, if it has type `kind` (an `rt`
|
||
/// constant): a crafted id can't make a chart of some other, possibly huge, part.
|
||
fn part_of(rels: &Rels, id: &str, kind: &str) -> Option<String> {
|
||
rels.by_id(id).filter(|r| !r.external && rel_is(&r.kind, kind)).map(|r| r.target.clone())
|
||
}
|
||
|
||
/// Read a `.docx` package.
|
||
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||
// A password-protected document is a compound file, not a zip: say so rather than failing
|
||
// as a broken zip (`read_with_password` opens it).
|
||
if crate::is_encrypted(bytes) {
|
||
return crate::read_with_password(bytes, None);
|
||
}
|
||
read_package(bytes)
|
||
}
|
||
|
||
/// [`read`] for bytes known not to be an encrypted package.
|
||
pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||
read_opened(&Package::open(bytes)?)
|
||
}
|
||
|
||
/// Read a package whose parts are already unpacked (from a zip, a Flat OPC file or a converted
|
||
/// Word 2003 XML document).
|
||
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||
let root_rels = pkg.rels("");
|
||
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
|
||
let Some(root) = pkg.xml(&main)? else {
|
||
return Err(DocxError::MissingPart(main));
|
||
};
|
||
if root.name != "w:document" {
|
||
return Err(DocxError::NotWord(format!("root element is {}", root.name)));
|
||
}
|
||
let rels = pkg.rels(&main);
|
||
let mut doc = Document::new();
|
||
doc.body.clear();
|
||
let mut r = Reader {
|
||
pkg,
|
||
doc,
|
||
pc: PropCtx::default(),
|
||
revs: HashMap::new(),
|
||
moves: MoveCtx::default(),
|
||
media_by_path: HashMap::new(),
|
||
hf_by_path: HashMap::new(),
|
||
footnotes: HashMap::new(),
|
||
endnotes: HashMap::new(),
|
||
current_note: None,
|
||
in_note_sep: false,
|
||
comment_map: HashMap::new(),
|
||
comments_ended: HashSet::new(),
|
||
bookmarks: HashMap::new(),
|
||
perms: HashMap::new(),
|
||
graphic_parts: HashMap::new(),
|
||
graphics: HashMap::new(),
|
||
graphic_budget: MAX_GRAPHIC_WORK,
|
||
glued: HashSet::new(),
|
||
shape_ids: false,
|
||
main: main.clone(),
|
||
content_types: None,
|
||
embedded: EmbeddedManifest::default(),
|
||
txbx_links: Vec::new(),
|
||
};
|
||
r.pc.major_font = r.doc.settings.major_font.clone();
|
||
r.pc.minor_font = r.doc.settings.minor_font.clone();
|
||
let part = |t: &str| rels.by_type(t).filter(|x| !x.external).map(|x| x.target.clone());
|
||
|
||
if let Some(p) = part(rt::THEME) {
|
||
r.read_theme(&p);
|
||
}
|
||
// Secondary parts are optional: a broken one is skipped (logged), not fatal.
|
||
let lenient = |what: &str, res: Result<(), DocxError>| {
|
||
if let Err(e) = res {
|
||
log::warn!("docx: ignoring unreadable {what}: {e}");
|
||
}
|
||
};
|
||
// Styles come before settings, but their theme colours already follow its colour mapping.
|
||
if let Some(p) = part(rt::SETTINGS) {
|
||
r.read_color_mapping(&p);
|
||
}
|
||
if let Some(p) = part(rt::STYLES) {
|
||
lenient("styles", r.read_styles(&p));
|
||
}
|
||
if let Some(p) = part(rt::NUMBERING) {
|
||
lenient("numbering", r.read_numbering(&p));
|
||
}
|
||
// Without a stated mode (or settings at all) Word lays a document out as Word 2007 did.
|
||
r.doc.settings.compat_mode = wordcraft_doc::LEGACY_COMPAT_MODE;
|
||
if let Some(p) = part(rt::SETTINGS) {
|
||
lenient("settings", r.read_settings(&p));
|
||
}
|
||
if let Some(p) = part(rt::FOOTNOTES) {
|
||
lenient("footnotes", r.read_notes(&p, true));
|
||
}
|
||
if let Some(p) = part(rt::ENDNOTES) {
|
||
lenient("endnotes", r.read_notes(&p, false));
|
||
}
|
||
if let Some(p) = part(rt::COMMENTS) {
|
||
let ex = part(rt::COMMENTS_EX);
|
||
lenient("comments", r.read_comments(&p, ex.as_deref()));
|
||
}
|
||
|
||
if let Some(bg) = root.child("w:background").and_then(|b| b.attr("w:color")).and_then(Rgb::parse) {
|
||
r.doc.settings.page_color = Some(bg);
|
||
}
|
||
if let Some(body) = root.child("w:body") {
|
||
let mut sc = StoryCtx::default();
|
||
let mut blocks = Blocks::new();
|
||
r.read_blocks(&mut sc, body, &rels, &mut blocks, 0);
|
||
r.flush_pending(&mut sc, &mut blocks);
|
||
toc_field_to_heading(&mut blocks);
|
||
r.doc.body = blocks;
|
||
if let Some(s) = body.child("w:sectPr") {
|
||
r.doc.last_section = r.read_section(s, &rels);
|
||
}
|
||
}
|
||
|
||
let core = root_rels.by_type(rt::CORE).map(|x| x.target.clone()).unwrap_or_else(|| "docProps/core.xml".into());
|
||
r.read_core(&core);
|
||
if let Some(c) = root_rels.by_type(rt::CUSTOM)
|
||
&& let Ok(Some(x)) = r.xml(&c.target)
|
||
{
|
||
r.doc.custom_props = crate::custom::read(&x);
|
||
}
|
||
// A macro project and every part it relates to (VBA data, signatures…) ride along as opaque
|
||
// bytes, never parsed or run, so a .docm/.dotm saved again carries exactly what the file had.
|
||
// Only the relationships and content types are read.
|
||
if let Some(v) = part(rt::VBA_PROJECT)
|
||
&& let Some(b) = pkg.get(&v).filter(|b| !b.is_empty())
|
||
{
|
||
r.doc.passthrough.insert(VBA_PROJECT_PART.into(), Arc::new(b.to_vec()));
|
||
r.read_vba_related(&v);
|
||
}
|
||
if !r.embedded.parts.is_empty() {
|
||
let manifest = r.embedded.to_bytes();
|
||
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
|
||
}
|
||
r.finish_moves();
|
||
let links = std::mem::take(&mut r.txbx_links);
|
||
link_text_boxes(&mut r.doc, links);
|
||
if r.shape_ids {
|
||
keep_glued_ids(&mut r.doc, &r.glued);
|
||
}
|
||
let mut doc = r.doc;
|
||
doc.ensure_nonempty();
|
||
Ok(doc)
|
||
}
|
||
|
||
/// Most move ranges open at once, and most move names tracked, per file.
|
||
const MAX_OPEN_MOVES: usize = 64;
|
||
const MAX_MOVES: usize = 100_000;
|
||
|
||
/// The move ranges of a file (ECMA-376 §17.13.5.22–27): a `w:moveFrom` / `w:moveTo` is one end
|
||
/// of a move when it sits inside a range of that kind; the range's name links both ends.
|
||
#[derive(Default)]
|
||
pub(crate) struct MoveCtx {
|
||
/// Open source and destination ranges: (`w:id`, `w:name`).
|
||
open: [Vec<(String, String)>; 2],
|
||
/// Per name: a source range started / ended, a destination range started / ended (bits 0–3).
|
||
seen: HashMap<String, u8>,
|
||
/// The range of each kind that ended last in the paragraph being read (for its mark).
|
||
pub closed_here: [Option<String>; 2],
|
||
}
|
||
|
||
impl Reader<'_> {
|
||
/// A `w:moveFromRangeStart/End` or `w:moveToRangeStart/End`.
|
||
pub fn move_range(&mut self, e: &El) {
|
||
let (side, start) = match e.name.as_str() {
|
||
"w:moveFromRangeStart" => (0, true),
|
||
"w:moveFromRangeEnd" => (0, false),
|
||
"w:moveToRangeStart" => (1, true),
|
||
"w:moveToRangeEnd" => (1, false),
|
||
_ => return,
|
||
};
|
||
let id = e.attr("w:id").unwrap_or("").chars().take(64).collect::<String>();
|
||
let m = &mut self.moves;
|
||
let Some(open) = m.open.get_mut(side) else { return };
|
||
if start {
|
||
let name = e.attr("w:name").unwrap_or("").chars().take(256).collect::<String>();
|
||
if name.is_empty() || open.len() >= MAX_OPEN_MOVES || (m.seen.len() >= MAX_MOVES && !m.seen.contains_key(&name)) {
|
||
return;
|
||
}
|
||
*m.seen.entry(name.clone()).or_default() |= 1 << (side * 2);
|
||
open.push((id, name));
|
||
} else if let Some(i) = open.iter().rposition(|(x, _)| *x == id) {
|
||
let (_, name) = open.remove(i);
|
||
*m.seen.entry(name.clone()).or_default() |= 2 << (side * 2);
|
||
if let Some(c) = m.closed_here.get_mut(side) {
|
||
*c = Some(name);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The revision of a `w:moveFrom` (`to` false) or `w:moveTo`: one end of the innermost open
|
||
/// range of its kind, else (no range) a plain deletion or insertion.
|
||
pub fn move_revision(&mut self, to: bool, e: &El, mark: bool) -> u32 {
|
||
let side = usize::from(to);
|
||
let open = self.moves.open.get(side).and_then(|o| o.last()).map(|(_, n)| n.clone());
|
||
// A paragraph mark comes after its paragraph's text: a range that ended in it counts.
|
||
let name = if mark { open.or_else(|| self.moves.closed_here.get(side).cloned().flatten()) } else { open };
|
||
let kind = if to { RevisionKind::Insert } else { RevisionKind::Delete };
|
||
self.revision_named(kind, e, name)
|
||
}
|
||
|
||
/// Moves missing an end (a source or destination range, or its text) are plain deletions
|
||
/// and insertions.
|
||
fn finish_moves(&mut self) {
|
||
let mut ends: HashMap<&str, u8> = HashMap::new();
|
||
for r in &self.doc.revisions {
|
||
if let Some(n) = r.move_name.as_deref() {
|
||
*ends.entry(n).or_default() |= if r.kind == RevisionKind::Delete { 1 } else { 2 };
|
||
}
|
||
}
|
||
let ok: HashSet<String> =
|
||
ends.into_iter().filter(|(n, e)| *e == 3 && self.moves.seen.get(*n) == Some(&0b1111)).map(|(n, _)| n.to_string()).collect();
|
||
for r in &mut self.doc.revisions {
|
||
if r.move_name.as_ref().is_some_and(|n| !ok.contains(n)) {
|
||
r.move_name = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Link the text boxes read as (id, seq, part) into chains (see [`Reader::txbx_links`]): each
|
||
/// id's boxes in `seq` order after the box holding the text. A repeated seq, a chain without its
|
||
/// first box or more than [`wordcraft_doc::MAX_LINKED_BOXES`] boxes: the extra boxes stay apart.
|
||
fn link_text_boxes(doc: &mut Document, mut links: Vec<(u32, u32, u32)>) {
|
||
links.sort_by_key(|l| (l.0, l.1));
|
||
links.dedup_by_key(|l| (l.0, l.1));
|
||
for group in links.chunk_by(|a, b| a.0 == b.0) {
|
||
if group.first().is_none_or(|l| l.1 != 0) {
|
||
continue;
|
||
}
|
||
let chain: Vec<u32> = group.iter().take(wordcraft_doc::MAX_LINKED_BOXES).map(|l| l.2).collect();
|
||
for pair in chain.windows(2) {
|
||
if let [a, b] = pair
|
||
&& let Some(p) = doc.parts.get_mut(a)
|
||
{
|
||
p.body.next = Some(*b);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Drop the drawing ids read with shapes, except those connectors are glued to (so a shape
|
||
/// without connectors reads back as it was written).
|
||
fn keep_glued_ids(doc: &mut Document, glued: &HashSet<u32>) {
|
||
let has_id = |o: &InlineObject| match o {
|
||
InlineObject::Shape { extra, .. } => extra.id != 0,
|
||
InlineObject::Group { children, .. } => children.iter().any(|c| matches!(&c.obj, InlineObject::Shape { extra, .. } if extra.id != 0)),
|
||
_ => false,
|
||
};
|
||
let clear = |o: &mut InlineObject| {
|
||
if let InlineObject::Shape { extra, .. } = o
|
||
&& !glued.contains(&extra.id)
|
||
{
|
||
extra.id = 0;
|
||
}
|
||
};
|
||
doc.objects_mut(&has_id, &mut |o| match o {
|
||
InlineObject::Group { children, .. } => children.iter_mut().for_each(|c| clear(&mut c.obj)),
|
||
o => clear(o),
|
||
});
|
||
}
|
||
|
||
/// Word starts a TOC field in the first entry; the engine keeps it at the end of the TOC heading
|
||
/// (the paragraph holding the field marks where the TOC is). Move it there when one precedes it.
|
||
fn toc_field_to_heading(body: &mut Blocks) {
|
||
for i in 1..body.len() {
|
||
let heading = body[i - 1].as_para().filter(|p| p.props.style.as_deref() == Some("TOCHeading"));
|
||
let has_toc = |o: &InlineObject| matches!(o, InlineObject::Field { instr, .. } if is_toc(instr));
|
||
if heading.is_none_or(|h| h.objects.iter().any(has_toc)) {
|
||
continue;
|
||
}
|
||
let Some((field, props)) = body[i].as_para().and_then(|p| match p.object_at(0) {
|
||
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => Some((f.clone(), p.props_of_char(0).clone())),
|
||
_ => None,
|
||
}) else {
|
||
continue;
|
||
};
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn is_toc(instr: &str) -> bool {
|
||
instr.trim_start().get(..3).is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
|
||
}
|
||
|
||
/// 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<'_> {
|
||
/// 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.
|
||
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(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(|| Arc::new(bytes.to_vec()));
|
||
}
|
||
if !manifest.is_empty() {
|
||
self.doc.passthrough.insert(VBA_RELATED.into(), Arc::new(manifest.into_bytes()));
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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(&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(), Arc::new(bytes.to_vec()));
|
||
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).
|
||
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 {
|
||
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();
|
||
let mut blocks = Blocks::new();
|
||
self.read_blocks(&mut sc, &root, &rels, &mut blocks, 0);
|
||
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)
|
||
}
|
||
|
||
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;
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
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.
|
||
fn read_color_mapping(&mut self, path: &str) {
|
||
let Ok(Some(root)) = self.xml(path) else { return };
|
||
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);
|
||
}
|
||
|
||
fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
|
||
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());
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
if sheet.default_chr.size.is_none() {
|
||
sheet.default_chr.size = Some(10.0);
|
||
}
|
||
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);
|
||
if sheet.get(&id).is_some() {
|
||
continue;
|
||
}
|
||
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),
|
||
..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);
|
||
}
|
||
sheet.styles.push(st);
|
||
}
|
||
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).filter(|n| *n > 0).map(|n| n.min(1000));
|
||
}
|
||
for c in s.children("w:tblStylePr").take(64) {
|
||
let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e));
|
||
let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders);
|
||
let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default();
|
||
match c.attr("w:type") {
|
||
Some("wholeTable") => {
|
||
if let Some(p) = c.child("w:pPr") {
|
||
st.para.overlay(&self.pc.ppr(p).0);
|
||
}
|
||
st.chr.overlay(&chr);
|
||
if fill.is_some() {
|
||
t.fill = fill;
|
||
}
|
||
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
|
||
t.borders = Some(props::borders(b));
|
||
}
|
||
}
|
||
Some("firstRow") => {
|
||
t.header_fill = fill;
|
||
t.header_chr = chr;
|
||
t.header_borders = cell_borders;
|
||
}
|
||
Some("band1Horz") => {
|
||
t.band_fill = fill;
|
||
t.band_chr = chr;
|
||
t.band_borders = cell_borders;
|
||
}
|
||
Some("firstCol") => {
|
||
t.first_col_chr = chr;
|
||
t.first_col_fill = fill;
|
||
t.first_col_borders = cell_borders;
|
||
}
|
||
Some("lastCol") => {
|
||
t.last_col_chr = chr;
|
||
t.last_col_fill = fill;
|
||
t.last_col_borders = cell_borders;
|
||
}
|
||
Some("lastRow") => {
|
||
t.total_chr = chr;
|
||
t.total_fill = fill;
|
||
// The top rule is kept on its own (as built-in styles have it); the other edges
|
||
// are the region's borders.
|
||
t.total_border_top = cell_borders.and_then(|b| b.top);
|
||
t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default());
|
||
}
|
||
Some("band1Vert") => {
|
||
t.col_band_fill = fill;
|
||
t.col_band_chr = chr;
|
||
t.col_band_borders = cell_borders;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
t
|
||
}
|
||
|
||
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()));
|
||
}
|
||
abstracts.push(AbstractNum { id, name: a.child_val("w:name").map(str::to_string), levels });
|
||
}
|
||
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
|
||
}
|
||
|
||
fn read_settings(&mut self, path: &str) -> Result<(), DocxError> {
|
||
let Some(root) = self.xml(path)? else { return Ok(()) };
|
||
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:compat" => {
|
||
// 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);
|
||
}
|
||
}
|
||
"w:trackRevisions" => s.track_changes = 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),
|
||
_ => {}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
fn read_comments(&mut self, path: &str, ext: 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();
|
||
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) || self.comment_map.values().any(|v| *v == 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,
|
||
},
|
||
);
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
self.doc.comments = entries;
|
||
Ok(())
|
||
}
|
||
|
||
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,
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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() }
|
||
}
|
||
|
||
/// 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();
|
||
}
|