diff --git a/Cargo.lock b/Cargo.lock index d0adfeda5..af0f070c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4657,6 +4657,7 @@ dependencies = [ "kurbo 0.13.1", "log", "proptest", + "roxmltree 0.21.1", "ruzstd", "serde", "serde_json", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 4e871482f..0a615dcdb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -35,6 +35,8 @@ vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } flate2 = { workspace = true } +# The XML parts of Word documents placed as type (place/docx.rs). +roxmltree = "0.21" kurbo = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index e6b745bb4..5da4fa250 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -8,7 +8,7 @@ //! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…). //! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`. //! - `screens`: Export for Screens (`document.exportForScreens`, `document.exportSettings`); `zip` -//! packs its files as one download. +//! packs its files as one download, and reads the files of Word documents placed as type. //! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the //! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend. //! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. @@ -45,7 +45,7 @@ mod save; mod screens; mod svg; mod text; -mod zip; +pub mod zip; use serde_json::{Value, json}; @@ -710,7 +710,11 @@ pub const TEXT_EXTS: &[&str] = &["txt"]; /// RTF files: File → Place sets them as area type with their formatting. pub const RTF_EXTS: &[&str] = &["rtf"]; -/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`] and [`RTF_EXTS`]. +/// Word documents: File → Place sets them as area type with their formatting. +pub const DOCX_EXTS: &[&str] = &["docx"]; + +/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`] and +/// [`DOCX_EXTS`]. pub const PLACE_EXTS: &[&str] = &[ "vectorcraft", "drawcraft", @@ -741,6 +745,7 @@ pub const PLACE_EXTS: &[&str] = &[ "afpub", "txt", "rtf", + "docx", ]; /// One dialog filter per readable format. @@ -764,13 +769,14 @@ pub fn open_filters() -> impl Iterator impl Iterator { std::iter::once(("All placeable files", PLACE_EXTS)) .chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext)))) .chain(std::iter::once(("Text", TEXT_EXTS))) .chain(std::iter::once(("RTF", RTF_EXTS))) + .chain(std::iter::once(("Word", DOCX_EXTS))) } /// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`). diff --git a/crates/engine/src/cmd/fileio/zip.rs b/crates/engine/src/cmd/fileio/zip.rs index b3a9a397b..f863c3cfd 100644 --- a/crates/engine/src/cmd/fileio/zip.rs +++ b/crates/engine/src/cmd/fileio/zip.rs @@ -1,5 +1,10 @@ //! A store-only (uncompressed) ZIP archive: several exported files as one download. The files are //! already compressed (PNG, JPEG, PDF…), so storing them keeps the archive fast to write and read. +//! +//! [`Archive`] reads the files of a ZIP archive, stored or deflated, as PKWARE's APPNOTE.TXT +//! describes the format (). + +use std::io::Read as _; use flate2::Crc; @@ -65,6 +70,145 @@ pub fn store, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result, Ok(out) } +/// Does `bytes` start with a ZIP archive's first local file header? +pub fn is_zip(bytes: &[u8]) -> bool { + bytes.starts_with(&0x0403_4b50u32.to_le_bytes()) +} + +/// Why [`Archive`] can't read an archive or one of its files. +#[derive(Debug, PartialEq)] +pub enum ReadError { + /// Not a ZIP archive: no end of central directory record. + NotZip, + /// More files than the reader was asked to accept. + TooManyFiles, + /// The archive needs the 64-bit (ZIP64) records, which the reader does not read. + Zip64, + /// The file is encrypted. + Encrypted(String), + /// The file is compressed with a method other than stored (0) or deflated (8). + Method(String, u16), + /// The file unpacks to more bytes than the reader was asked to accept. + TooLarge(String), + /// The archive (empty name) or the file is truncated, inconsistent or fails its CRC. + Damaged(String), +} + +/// One file of an archive, as its central directory record describes it. +struct Entry { + name: String, + flags: u16, + method: u16, + crc: u32, + packed: usize, + size: usize, + local: usize, +} + +/// A ZIP archive's files, listed from its central directory. +pub struct Archive<'a> { + zip: &'a [u8], + entries: Vec, +} + +impl<'a> Archive<'a> { + /// The archive `zip`, with at most `max_files` files. + pub fn open(zip: &'a [u8], max_files: usize) -> Result { + let damaged = || ReadError::Damaged(String::new()); + let u16_at = |at: usize| at.checked_add(2).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes); + let u32_at = |at: usize| at.checked_add(4).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + // The end record is 22 bytes followed by a comment of at most 65535 bytes: the signature + // whose comment ends the archive, else the last signature (the archive has bytes after it). + let last = zip.len().checked_sub(22).ok_or(ReadError::NotZip)?; + let candidates = || (last.saturating_sub(0xFFFF)..=last).rev().filter(|&at| u32_at(at) == Some(0x0605_4b50)); + let end = + candidates().find(|&at| u16_at(at + 20).map(usize::from) == Some(last - at)).or_else(|| candidates().next()).ok_or(ReadError::NotZip)?; + let count = u16_at(end + 10).ok_or_else(damaged)?; + let start = u32_at(end + 16).ok_or_else(damaged)?; + if count == 0xFFFF || start == 0xFFFF_FFFF || u32_at(end + 12) == Some(0xFFFF_FFFF) { + return Err(ReadError::Zip64); + } + let count = usize::from(count); + if count > max_files { + return Err(ReadError::TooManyFiles); + } + let mut at = start as usize; + let mut entries = Vec::with_capacity(count); + for _ in 0..count { + if u32_at(at) != Some(0x0201_4b50) { + return Err(damaged()); + } + let short = |off: usize| at.checked_add(off).and_then(u16_at).ok_or_else(damaged); + let long = |off: usize| at.checked_add(off).and_then(u32_at).ok_or_else(damaged); + let (name_len, extra, comment) = (usize::from(short(28)?), usize::from(short(30)?), usize::from(short(32)?)); + let name_at = at.checked_add(46).ok_or_else(damaged)?; + let name = name_at.checked_add(name_len).and_then(|end| zip.get(name_at..end)).ok_or_else(damaged)?; + entries.push(Entry { + name: String::from_utf8_lossy(name).into_owned(), + flags: short(8)?, + method: short(10)?, + crc: long(16)?, + packed: long(20)? as usize, + size: long(24)? as usize, + local: long(42)? as usize, + }); + at = name_at.checked_add(name_len + extra + comment).ok_or_else(damaged)?; + } + Ok(Self { zip, entries }) + } + + /// The file `name`, matched in any ASCII case. + fn entry(&self, name: &str) -> Option<&Entry> { + self.entries.iter().find(|e| e.name.eq_ignore_ascii_case(name)) + } + + /// Does the archive hold the file `name` (in any ASCII case)? + pub fn contains(&self, name: &str) -> bool { + self.entry(name).is_some() + } + + /// The bytes of the file `name` (matched in any ASCII case; None: the archive has no such + /// file), when it unpacks to at most `max` bytes and passes its CRC. + pub fn read(&self, name: &str, max: usize) -> Result>, ReadError> { + let Some(e) = self.entry(name) else { return Ok(None) }; + let damaged = || ReadError::Damaged(e.name.clone()); + if e.flags & 1 != 0 { + return Err(ReadError::Encrypted(e.name.clone())); + } + if e.size > max { + return Err(ReadError::TooLarge(e.name.clone())); + } + // The local header: its signature, then its name and extra field lengths at 26 and 28. + let header = e.local.checked_add(30).and_then(|end| self.zip.get(e.local..end)).ok_or_else(damaged)?; + let (Some(sig), Some(name_len), Some(extra)) = (header.get(..4), header.get(26..28), header.get(28..30)) else { return Err(damaged()) }; + if sig != 0x0403_4b50u32.to_le_bytes() { + return Err(damaged()); + } + let len = |b: &[u8]| b.try_into().map(|b| usize::from(u16::from_le_bytes(b))).map_err(|_| damaged()); + let start = e.local + 30 + len(name_len)? + len(extra)?; + let data = start.checked_add(e.packed).and_then(|end| self.zip.get(start..end)).ok_or_else(damaged)?; + let bytes = match e.method { + 0 if e.packed != e.size => return Err(damaged()), + 0 => data.to_vec(), + 8 => { + let mut out = Vec::with_capacity(e.size); + flate2::read::DeflateDecoder::new(data).take((max as u64).saturating_add(1)).read_to_end(&mut out).map_err(|_| damaged())?; + if out.len() > max { + return Err(ReadError::TooLarge(e.name.clone())); + } + out + } + m => return Err(ReadError::Method(e.name.clone(), m)), + }; + let mut crc = Crc::new(); + crc.update(&bytes); + if bytes.len() != e.size || crc.sum() != e.crc { + return Err(damaged()); + } + Ok(Some(bytes)) + } +} + /// The entries of a store-only archive written by [`store`] (read through its central directory, /// checking every CRC) → `(name, bytes)`. #[cfg(test)] diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs new file mode 100644 index 000000000..400f4d168 --- /dev/null +++ b/crates/engine/src/cmd/place/docx.rs @@ -0,0 +1,977 @@ +//! Placing a Word document (`.docx`): its text set as area type with the character and paragraph +//! formatting VectorCraft has, read as ECMA-376 Part 1 describes WordprocessingML +//! (), in its +//! Transitional and Strict namespaces. +//! +//! - The package is a ZIP archive ([`fileio::zip`]). `_rels/.rels` names the main document part +//! (else `word/document.xml`), and that part's relationships name its styles and theme parts +//! (else `styles.xml` and `theme/theme1.xml` next to it). +//! - Text: each paragraph (`w:p`) is a paragraph, and so is each table row, whose cells are +//! separated by tabs. A text-wrapping break (`w:br`) and a carriage return (`w:cr`) start a new +//! paragraph; page and column breaks are skipped. `w:tab` and `w:ptab` are tabs, and +//! `w:noBreakHyphen` and `w:softHyphen` are their hyphens. Text in insertions, moves, hyperlinks, +//! smart tags, content controls, custom XML and simple fields is read, and of a complex field, +//! its result. +//! - Character formatting: the font (`w:rFonts`: `w:asciiTheme`, `w:ascii`, `w:hAnsiTheme`, then +//! `w:hAnsi`; a theme font is the theme's major or minor Latin font), the size, bold, italic, +//! underline (every style but `none`), strikethrough and double strikethrough, all caps, small +//! caps, superscript and subscript (`w:vertAlign`), raised and lowered text (`w:position`) and +//! the color. +//! - Paragraph formatting: alignment (`w:jc`), start and end indents (`w:start` or `w:left`, +//! `w:end` or `w:right`: the right and left indents of a right-to-left paragraph), first-line and +//! hanging indents, space before and after, and direction (`w:bidi`). +//! - Styles (§17.7.2): the document defaults, then the paragraph style (else the default paragraph +//! style) and the styles it is based on, then the run's character style (else the default +//! character style) and the styles it is based on, then direct formatting. Bold, italic, caps, +//! small caps, strikethrough and hidden text are toggle properties (§17.7.3): a style that turns +//! one on reverses the state the levels before it give, and direct formatting sets it. +//! +//! Hidden text (`w:vanish`), deleted text, field instructions, drawings, pictures, objects, symbol +//! characters (`w:sym`), note and comment references, headers, footers, notes, comments and list +//! numbers are left out. A package with more than [`MAX_FILES`] files, a part that unpacks to more +//! than [`MAX_PART`] bytes or nests elements more than [`MAX_NESTING`] deep, and a document with +//! more than [`MAX_PARAGRAPHS`] paragraphs, [`MAX_RUNS`] runs of one formatting or +//! [`text::MAX_TEXT`] bytes of text are refused. Tables, content controls and other containers +//! nested more than [`MAX_DEPTH`] deep are skipped, and so are fields nested deeper. + +use std::collections::HashMap; + +use roxmltree::Node; +use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun}; + +use super::text::{self, Story}; +use super::*; +use crate::cmd::fileio::zip::{self, Archive, ReadError}; + +/// Word documents as `file.place` reports them (not a format `document.open` reads). +pub(super) const FORMAT: fileio::Format = fileio::Format { + id: "docx", + label: "Word", + extensions: fileio::DOCX_EXTS, + mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + read: true, + write: false, + raster: false, + options: &[], +}; + +/// The most files a Word document's package may hold. +const MAX_FILES: usize = 10_000; +/// The most bytes a part read from the package may unpack to. +const MAX_PART: usize = 16 << 20; +/// The most XML nodes a part read from the package may have. +const MAX_NODES: u32 = 2_000_000; +/// The deepest nesting of XML elements a part read from the package may have. +const MAX_NESTING: usize = 256; +/// The deepest nesting of tables, content controls and other containers read; deeper ones are +/// skipped, and so are fields nested deeper. +const MAX_DEPTH: usize = 64; +/// The most styles read from the styles part. +const MAX_STYLES: usize = 4096; +/// The most styles one style's `w:basedOn` chain takes in. +const MAX_BASED_ON: usize = 32; +/// The most fonts the document's formatting names. +const MAX_FONTS: usize = 4096; +/// The most bytes of a font name kept. +const MAX_NAME: usize = 256; +/// The most paragraphs placed type takes from one Word document. +const MAX_PARAGRAPHS: usize = 100_000; +/// The most runs of one character formatting placed type takes from one Word document. +const MAX_RUNS: usize = 100_000; + +/// WordprocessingML's namespaces: Transitional, then Strict. +const W: [&str; 2] = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"]; + +/// Is the file `name` with `bytes` a Word document: a ZIP archive named `.docx`, or one that holds +/// `word/document.xml`? +pub(super) fn is_docx(name: &str, bytes: &[u8]) -> bool { + zip::is_zip(bytes) + && (fileio::DOCX_EXTS.contains(&fileio::extension(name).as_str()) + || Archive::open(bytes, MAX_FILES).is_ok_and(|z| z.contains("word/document.xml"))) +} + +/// The text of a Word document's `bytes` (a ZIP package) with its character and paragraph +/// formatting. +pub(super) fn import(bytes: &[u8], cmd: &str) -> Result { + let zip = Archive::open(bytes, MAX_FILES).map_err(|e| package_error(cmd, e))?; + let main = relationship(&zip, "", "officeDocument", cmd)?.unwrap_or_else(|| "word/document.xml".into()); + let Some(xml) = part(&zip, &main, cmd)? else { return Err(bad(cmd, "not a Word document: its package has no main document part")) }; + let doc = parse(&xml, &main, cmd)?; + if local(doc.root_element()) != Some("document") { + return Err(bad(cmd, "not a Word document: its main part is not a WordprocessingML document")); + } + let folder = main.rfind('/').and_then(|i| main.get(..=i)).unwrap_or_default(); + let mut r = Reader::default(); + let theme = relationship(&zip, &main, "theme", cmd)?.unwrap_or_else(|| format!("{folder}theme/theme1.xml")); + if let Some(xml) = part(&zip, &theme, cmd)? { + r.theme_fonts(&parse(&xml, &theme, cmd)?); + } + let styles = relationship(&zip, &main, "styles", cmd)?.unwrap_or_else(|| format!("{folder}styles.xml")); + if let Some(xml) = part(&zip, &styles, cmd)? { + r.styles(&parse(&xml, &styles, cmd)?); + } + if let Some(body) = doc.root_element().children().find(|n| local(*n) == Some("body")) { + r.block(body, 0); + } + r.finish(cmd) +} + +/// The error for a package (or a part of it) the ZIP reader can't read. +fn package_error(cmd: &str, e: ReadError) -> EngineError { + bad( + cmd, + match e { + ReadError::NotZip => "not a Word document: it isn't a ZIP package".to_string(), + ReadError::TooManyFiles => format!("the Word document's package has more than {MAX_FILES} files"), + ReadError::Zip64 => "the Word document's package uses ZIP64 records, which VectorCraft doesn't read".to_string(), + ReadError::Encrypted(name) => format!("`{name}` in the Word document is encrypted"), + ReadError::Method(name, m) => format!("`{name}` in the Word document uses compression method {m}, which VectorCraft doesn't read"), + ReadError::TooLarge(name) => format!("`{name}` in the Word document is too large to place (at most {} MB unpacked)", MAX_PART >> 20), + ReadError::Damaged(name) if name.is_empty() => "the Word document's package is damaged".to_string(), + ReadError::Damaged(name) => format!("the Word document's package is damaged: `{name}` can't be read"), + }, + ) +} + +/// The part `name` of the package as text (UTF-8, or UTF-16 with a byte-order mark); None: the +/// package has no such part. +fn part(zip: &Archive, name: &str, cmd: &str) -> Result> { + let Some(bytes) = zip.read(name, MAX_PART).map_err(|e| package_error(cmd, e))? else { return Ok(None) }; + let utf16 = |rest: &[u8], le: bool| -> String { + let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) }); + char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect() + }; + let text = match &bytes[..] { + [0xEF, 0xBB, 0xBF, rest @ ..] => std::str::from_utf8(rest).map(str::to_string), + [0xFF, 0xFE, rest @ ..] => Ok(utf16(rest, true)), + [0xFE, 0xFF, rest @ ..] => Ok(utf16(rest, false)), + _ => std::str::from_utf8(&bytes).map(str::to_string), + }; + text.map(Some).map_err(|_| bad(cmd, format!("`{name}` in the Word document is not UTF-8 or UTF-16 text"))) +} + +/// The XML of the part `name`. +fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result> { + // roxmltree parses nested elements recursively. + if nesting(xml) > MAX_NESTING { + return Err(bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep"))); + } + let opts = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() }; + roxmltree::Document::parse_with_options(xml, opts).map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}"))) +} + +/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments, +/// CDATA sections, processing instructions, declarations and quoted attribute values. +fn nesting(xml: &str) -> usize { + let b = xml.as_bytes(); + let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize); + // The index just past the first `end` at or after `from` (the end of the text if none). + let past = |from: usize, end: &[u8]| { + b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len()) + }; + while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) { + let start = i + at; + let rest = b.get(start..).unwrap_or_default(); + if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else if rest.starts_with(b""); + } else { + // A start tag: up to its `>` outside quotes; `/>` closes it at once. + let mut j = start + 1; + let mut quote = None; + while let Some(&c) = b.get(j) { + match (quote, c) { + (None, b'"' | b'\'') => quote = Some(c), + (Some(q), _) if c == q => quote = None, + (None, b'>') => break, + _ => {} + } + j += 1; + } + if b.get(j.saturating_sub(1)) != Some(&b'/') { + depth += 1; + most = most.max(depth); + } + i = j + 1; + } + } + most +} + +/// The part that the first relationship of part `source` (`""`: the package) whose type ends in +/// `/{kind}` points to; None when the package has no relationships part for `source` or it names +/// no such part. +fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result> { + let (folder, file) = source.rfind('/').map_or(("", source), |i| (source.get(..=i).unwrap_or_default(), source.get(i + 1..).unwrap_or_default())); + let rels = format!("{folder}_rels/{file}.rels"); + let Some(xml) = part(zip, &rels, cmd)? else { return Ok(None) }; + let doc = parse(&xml, &rels, cmd)?; + let target = doc + .root_element() + .children() + .filter(|n| n.tag_name().name() == "Relationship" && n.attribute("TargetMode") != Some("External")) + .find(|n| n.attribute("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind))) + .and_then(|n| n.attribute("Target")); + Ok(target.map(|t| resolve(folder, t))) +} + +/// The part name `target` gives, relative to the folder `folder` (`""` or ending in `/`). +fn resolve(folder: &str, target: &str) -> String { + let joined = match target.strip_prefix('/') { + Some(absolute) => absolute.to_string(), + None => format!("{folder}{target}"), + }; + let mut parts: Vec<&str> = vec![]; + for segment in joined.split('/') { + match segment { + "" | "." => {} + ".." => { + parts.pop(); + } + s => parts.push(s), + } + } + parts.join("/") +} + +/// `n`'s local name when it is a WordprocessingML element. +fn local<'a>(n: Node<'a, '_>) -> Option<&'a str> { + (n.is_element() && n.tag_name().namespace().is_some_and(|ns| W.contains(&ns))).then(|| n.tag_name().name()) +} + +/// `n`'s first WordprocessingML child element named `name`. +fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option> { + n.children().find(|c| local(*c) == Some(name)) +} + +/// `n`'s WordprocessingML attribute `name`. +fn attr<'a>(n: Node<'a, '_>, name: &str) -> Option<&'a str> { + n.attributes().find(|a| a.name() == name && a.namespace().is_some_and(|ns| W.contains(&ns))).map(|a| a.value()) +} + +/// An on/off property (ST_OnOff): on unless `w:val` is `false`, `0` or `off`. +fn on(n: Node) -> bool { + !matches!(attr(n, "val"), Some("false" | "0" | "off")) +} + +/// A measure `v` in units of `1 / per_pt` point: a number in those units, or a number with a unit +/// (`mm`, `cm`, `in`, `pt`, `pc`, `pi`). +fn measure(v: &str, per_pt: f64) -> Option { + const UNITS: [(&str, f64); 6] = [("mm", 72.0 / 25.4), ("cm", 72.0 / 2.54), ("in", 72.0), ("pt", 1.0), ("pc", 12.0), ("pi", 12.0)]; + let v = v.trim(); + let n = match UNITS.iter().find_map(|(unit, pt)| v.strip_suffix(unit).map(|n| (n, pt))) { + Some((n, pt)) => n.trim().parse::().ok()? * pt * per_pt, + None => v.parse::().ok()?, + }; + n.is_finite().then_some(n) +} + +/// Twentieths of a point. +fn twips(n: Node, name: &str) -> Option { + attr(n, name).and_then(|v| measure(v, 20.0)) +} + +/// Half points. +fn half_points(n: Node) -> Option { + attr(n, "val").and_then(|v| measure(v, 2.0)) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Script { + #[default] + Normal, + Super, + Sub, +} + +/// The run properties one level of the style hierarchy sets (None: it doesn't set that one). +#[derive(Clone, Copy, Debug, Default)] +struct RunProps { + /// The font's number in [`Reader::fonts`]. + font: Option, + half_points: Option, + bold: Option, + italic: Option, + caps: Option, + small_caps: Option, + strike: Option, + dstrike: Option, + hidden: Option, + underline: Option, + script: Option