///|
priv struct Relationship {
id : String
relationship_type : String
target : String
target_mode : String?
}
///|
priv struct Relationships {
relationships : Array[Relationship]
}
///|
/// One input-linear index shared by package projection and strict annotation
/// validation. Values stay arrays so duplicate relationship IDs retain their
/// fail-closed meaning instead of being silently overwritten.
priv struct RelationshipIndex {
by_id : StableStringMap[Array[Relationship]]
}
///|
fn RelationshipIndex::build(relationships : Relationships) -> RelationshipIndex {
let by_id : StableStringMap[Array[Relationship]] = SortedMap([])
for relationship in relationships.relationships {
let matches = match by_id.get(relationship.id) {
Some(existing) => existing
None => {
let fresh : Array[Relationship] = []
by_id[relationship.id] = fresh
fresh
}
}
matches.push(relationship)
}
{ by_id, }
}
///|
/// Relationship IDs are package-scoped identities. Missing and duplicate IDs
/// are both unresolved here: callers must never choose an attacker-controlled
/// first declaration from an ambiguous relationship set.
fn RelationshipIndex::find_unique_by_id(
self : RelationshipIndex,
id : String,
) -> Relationship? {
match self.by_id.get(id) {
Some([relationship]) => Some(relationship)
_ => None
}
}
///|
fn RelationshipIndex::find_unique_target_by_id(
self : RelationshipIndex,
id : String,
) -> String? {
match self.find_unique_by_id(id) {
Some(relationship) => Some(relationship.target)
None => None
}
}
///|
const TRANSITIONAL_OFFICE_DOCUMENT_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
///|
const STRICT_OFFICE_DOCUMENT_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument"
///|
const PACKAGE_RELATIONSHIPS_ROOT : String = "{http://schemas.openxmlformats.org/package/2006/relationships}Relationships"
///|
const PACKAGE_RELATIONSHIP_ELEMENT : String = "{http://schemas.openxmlformats.org/package/2006/relationships}Relationship"
///|
const TRANSITIONAL_COMMENTS_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
///|
const STRICT_COMMENTS_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/comments"
///|
const COMMENTS_EXTENDED_RELATIONSHIP : String = "http://schemas.microsoft.com/office/2011/relationships/commentsExtended"
///|
const TRANSITIONAL_HEADER_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"
///|
const TRANSITIONAL_FOOTER_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"
///|
const TRANSITIONAL_FOOTNOTES_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes"
///|
const TRANSITIONAL_ENDNOTES_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes"
///|
const STRICT_HEADER_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/header"
///|
const STRICT_FOOTER_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/footer"
///|
const STRICT_FOOTNOTES_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/footnotes"
///|
const STRICT_ENDNOTES_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/endnotes"
///|
const TRANSITIONAL_STYLES_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"
///|
const STRICT_STYLES_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/styles"
///|
const TRANSITIONAL_NUMBERING_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering"
///|
const STRICT_NUMBERING_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/numbering"
///|
const STYLES_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"
///|
const NUMBERING_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"
///|
const COMMENTS_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"
///|
const COMMENTS_EXTENDED_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"
///|
const FOOTNOTES_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml"
///|
const ENDNOTES_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml"
///|
const HEADER_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"
///|
const FOOTER_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"
///|
priv enum OoxmlDialect {
TransitionalOoxml
StrictOoxml
}
///|
fn same_ooxml_dialect(left : OoxmlDialect, right : OoxmlDialect) -> Bool {
match (left, right) {
(TransitionalOoxml, TransitionalOoxml) | (StrictOoxml, StrictOoxml) => true
_ => false
}
}
///|
fn wordprocessing_dialect(uri : String) -> OoxmlDialect? {
match uri {
WORDPROCESSINGML_NAMESPACE => Some(TransitionalOoxml)
STRICT_WORDPROCESSINGML_NAMESPACE => Some(StrictOoxml)
_ => None
}
}
///|
fn office_relationship_dialect(relationship_type : String) -> OoxmlDialect? {
match relationship_type {
TRANSITIONAL_OFFICE_DOCUMENT_RELATIONSHIP => Some(TransitionalOoxml)
STRICT_OFFICE_DOCUMENT_RELATIONSHIP => Some(StrictOoxml)
_ => None
}
}
///|
fn OoxmlDialect::namespace_uri(self : OoxmlDialect) -> String {
match self {
TransitionalOoxml => WORDPROCESSINGML_NAMESPACE
StrictOoxml => STRICT_WORDPROCESSINGML_NAMESPACE
}
}
///|
fn OoxmlDialect::comments_relationship_type(self : OoxmlDialect) -> String {
match self {
TransitionalOoxml => TRANSITIONAL_COMMENTS_RELATIONSHIP
StrictOoxml => STRICT_COMMENTS_RELATIONSHIP
}
}
///|
/// One normalized main-relationship target whose XML can carry annotation
/// markers or paragraph identities. `kind` is one of header/footer/footnotes/
/// endnotes. Relationship order is preserved; normalized duplicate targets are
/// emitted once.
priv struct ReachableAnnotationStoryPart {
kind : String
path : String
relationship_dialect : OoxmlDialect
}
///|
priv struct RelatedWordprocessingPart {
path : String
relationship_dialect : OoxmlDialect
}
///|
priv enum WordprocessingPartRole {
StylesPartRole
NumberingPartRole
}
///|
fn WordprocessingPartRole::label(self : WordprocessingPartRole) -> String {
match self {
StylesPartRole => "styles"
NumberingPartRole => "numbering"
}
}
///|
fn WordprocessingPartRole::content_type(
self : WordprocessingPartRole,
) -> String {
match self {
StylesPartRole => STYLES_CONTENT_TYPE
NumberingPartRole => NUMBERING_CONTENT_TYPE
}
}
///|
fn same_wordprocessing_part_role(
left : WordprocessingPartRole,
right : WordprocessingPartRole,
) -> Bool {
match (left, right) {
(StylesPartRole, StylesPartRole) | (NumberingPartRole, NumberingPartRole) =>
true
_ => false
}
}
///|
priv struct StyleInfo {
style_id : String
style_type : String
name : String
}
///|
priv struct NumberingLevel {
is_ordered : Bool
level : Int
paragraph_style_id : String?
}
///|
priv struct AbstractNumbering {
levels : StableStringMap[NumberingLevel]
num_style_link : String?
}
///|
priv enum NumberingLinkResolution {
ResolvedAbstractNumbering(String)
UnresolvableNumbering
}
///|
priv struct NumberingMap {
abstract_nums : StableStringMap[AbstractNumbering]
levels_by_paragraph_style_id : StableStringMap[NumberingLevel]
resolved_abstract_num_ids : StableStringMap[NumberingLinkResolution]
}
///|
fn NumberingMap::empty() -> NumberingMap {
{
abstract_nums: SortedMap([]),
levels_by_paragraph_style_id: SortedMap([]),
resolved_abstract_num_ids: SortedMap([]),
}
}
///|
priv struct ContentTypes {
defaults : StableStringMap[String]
overrides : StableStringMap[String]
}
///|
/// Document tree plus diagnostics produced while reading a DOCX package.
pub(all) struct DocxReadResult {
document : DocumentElement
messages : Array[Message]
} derive(Debug, Eq)
///|
priv struct ExternalFileAccess {
enabled : Bool
read_file : (String) -> Bytes?
}
///|
fn no_external_file_reader(_path : String) -> Bytes? {
None
}
///|
fn default_external_file_access() -> ExternalFileAccess {
external_file_access_options(false, no_external_file_reader)
}
///|
fn external_file_access_options(
enabled : Bool,
read_file : (String) -> Bytes?,
) -> ExternalFileAccess {
{ enabled, read_file, }
}
///|
priv struct NotesReadResult {
notes : Array[Note]
// Raw identity attributes stay aligned with `notes`. The package
// projection intentionally keeps Mammoth's legacy `"undefined"` spelling;
// D2 derives its missing-id spelling from this lossless source separately.
annotation_ids : Array[String?]
messages : Array[Message]
}
///|
priv struct CommentsReadResult {
comments : Array[Comment]
// Raw identity attributes stay aligned with `comments`; see
// `NotesReadResult::annotation_ids` above.
annotation_ids : Array[String?]
messages : Array[Message]
}
///|
priv struct TableRowForSpan {
cells : Array[TableCellForSpan]
/// Trace items among the row's children after its last cell.
trailing : Array[ReaderItem]
is_header : Bool
source : ReaderNode
/// Trace items that preceded this row among the table's children.
leading : Array[ReaderItem]
}
///|
priv struct TableCellForSpan {
children : Array[ReaderItem]
col_span : Int
row_span : Int
source : ReaderNode
/// Trace items that preceded this cell among the row's children.
leading : Array[ReaderItem]
}
///|
/// Converts DOCX bytes to HTML.
pub fn convert_to_html(
docx : BytesView,
style_map? : Array[String] = [],
include_default_style_map? : Bool = true,
include_embedded_style_map? : Bool = true,
ignore_empty_paragraphs? : Bool = true,
id_prefix? : String = "",
pretty_print? : Bool = false,
convert_image? : (Image) -> ImageConversion = data_uri_image_converter,
transform_document? : (DocumentElement) -> DocumentElement = identity_document_transform,
external_file_access? : Bool = false,
read_external_file? : (String) -> Bytes? = no_external_file_reader,
) -> ConversionResult raise DocxError {
convert(
docx,
output_format=Html,
style_map~,
include_default_style_map~,
include_embedded_style_map~,
ignore_empty_paragraphs~,
id_prefix~,
pretty_print~,
convert_image~,
transform_document~,
external_file_access~,
read_external_file~,
)
}
///|
/// Converts DOCX bytes to Markdown.
pub fn convert_to_markdown(
docx : BytesView,
style_map? : Array[String] = [],
include_default_style_map? : Bool = true,
include_embedded_style_map? : Bool = true,
ignore_empty_paragraphs? : Bool = true,
id_prefix? : String = "",
convert_image? : (Image) -> ImageConversion = data_uri_image_converter,
transform_document? : (DocumentElement) -> DocumentElement = identity_document_transform,
external_file_access? : Bool = false,
read_external_file? : (String) -> Bytes? = no_external_file_reader,
) -> ConversionResult raise DocxError {
convert(
docx,
output_format=Markdown,
style_map~,
include_default_style_map~,
include_embedded_style_map~,
ignore_empty_paragraphs~,
id_prefix~,
convert_image~,
transform_document~,
external_file_access~,
read_external_file~,
)
}
///|
/// Converts DOCX bytes using the requested output format.
pub fn convert(
docx : BytesView,
output_format? : OutputFormat = Html,
style_map? : Array[String] = [],
include_default_style_map? : Bool = true,
include_embedded_style_map? : Bool = true,
ignore_empty_paragraphs? : Bool = true,
id_prefix? : String = "",
pretty_print? : Bool = false,
convert_image? : (Image) -> ImageConversion = data_uri_image_converter,
transform_document? : (DocumentElement) -> DocumentElement = identity_document_transform,
external_file_access? : Bool = false,
read_external_file? : (String) -> Bytes? = no_external_file_reader,
) -> ConversionResult raise DocxError {
let zip = open_zip(docx)
let read_result = read_docx_zip_with_messages(
zip,
external_files=external_file_access_options(
external_file_access, read_external_file,
),
)
let style_map = combine_docx_style_maps_from_zip(
zip,
style_map,
include_embedded_style_map~,
)
let result = convert_document(
read_result.document,
output_format~,
style_map~,
include_default_style_map~,
ignore_empty_paragraphs~,
id_prefix~,
pretty_print~,
convert_image~,
transform_document~,
)
prepend_messages(result, read_result.messages)
}
///|
/// Extracts raw text from DOCX bytes.
pub fn extract_raw_text(
docx : BytesView,
external_file_access? : Bool = false,
read_external_file? : (String) -> Bytes? = no_external_file_reader,
) -> ConversionResult raise DocxError {
let read_result = read_docx_zip_with_messages(
open_zip(docx),
external_files=external_file_access_options(
external_file_access, read_external_file,
),
read_images=false,
)
let result = extract_raw_text_from_document(read_result.document)
prepend_messages(result, read_result.messages)
}
///|
fn prepend_messages(
result : ConversionResult,
messages : Array[Message],
) -> ConversionResult {
if messages.is_empty() {
result
} else {
let combined : Array[Message] = []
combined.append(messages)
combined.append(result.messages)
{ value: result.value, messages: @core.dedupe_messages(combined), }
}
}
///|
/// Reads the embedded style map from DOCX bytes, if present.
pub fn read_embedded_style_map(docx : BytesView) -> String? raise DocxError {
read_embedded_style_map_from_zip(open_zip(docx))
}
///|
fn combine_docx_style_maps_from_zip(
zip : ZipArchive,
explicit_style_map : Array[String],
include_embedded_style_map~ : Bool,
) -> Array[String] raise DocxError {
let combined : Array[String] = []
combined.append(explicit_style_map)
if include_embedded_style_map {
match read_embedded_style_map_from_zip(zip) {
Some(style_map) => combined.append(read_style_map_string(style_map))
None => ()
}
}
combined
}
///|
fn read_embedded_style_map_from_zip(
zip : ZipArchive,
) -> String? raise DocxError {
zip.read_text("mammoth/style-map")
}
///|
/// Reads DOCX bytes into a document tree.
pub fn read_docx(
docx : BytesView,
external_file_access? : Bool = false,
read_external_file? : (String) -> Bytes? = no_external_file_reader,
) -> DocumentElement raise DocxError {
read_docx_with_messages(docx, external_file_access~, read_external_file~).document
}
///|
/// Reads DOCX bytes into a document tree and diagnostics.
pub fn read_docx_with_messages(
docx : BytesView,
external_file_access? : Bool = false,
read_external_file? : (String) -> Bytes? = no_external_file_reader,
) -> DocxReadResult raise DocxError {
read_docx_zip_with_messages(
open_zip(docx),
external_files=external_file_access_options(
external_file_access, read_external_file,
),
)
}
///|
/// The package projection retains Mammoth's JavaScript-derived `"undefined"`
/// sentinel. D2 identity is derived separately from the raw optional
/// attribute retained beside each parsed annotation definition.
fn annotation_id_or_missing(value : String?) -> String {
match value {
Some(id) => id
None => "undefined"
}
}
///|
/// The assembled per-document reading context: every part-derived input the
/// body/story readers need. Shared by `read_docx_zip_with_messages` (the
/// frozen body-only surface) and `read_docx_package` (body + sections +
/// header/footer stories).
priv struct DocumentParts {
zip : ZipArchive
xml_budget : @xml.XmlReadBudget?
diagnostics : ReaderDiagnosticCollector?
main_document_path : String
main_relationship_dialect : OoxmlDialect?
root : XmlElement
styles : StableStringMap[StyleInfo]
numbering : NumberingMap
content_types : ContentTypes
relationships : Relationships
relationship_index : RelationshipIndex
base_path : String
notes_result : NotesReadResult
comments_result : CommentsReadResult
// Authoritative main-part relationship targets. `None` means no such
// relationship was declared; legacy filename fallbacks remain reader-only
// and are never exposed as wired annotation parts.
comments_part_path : String?
comments_extended_part_path : String?
footnotes_part_path : String?
endnotes_part_path : String?
external_files : ExternalFileAccess
read_images : Bool
/// Which reader semantics story parts use. Only mutation-safe
/// annotated reads select the joined mode.
story_reader_mode : StoryReaderMode
/// The classified projection of every joined-read story part, keyed
/// by part path, and the EXACT source bytes each projection was built
/// from -- the binding that lets a planner refuse a mismatched
/// archive at planning time. Both empty in tolerant mode.
reader_projections : StableStringMap[ReaderProjection]
reader_projection_sources : StableStringMap[BytesView]
// Erase-order paragraph provenance per mutation-safe story part: one
// physical-identity vector per tree Paragraph occurrence (paraId R1b's
// join channel; absent for tolerant reads).
reader_paragraph_provenance : StableStringMap[Array[Array[SourceElementId?]]]
// PARAGRAPH style ids declared by the styles part, for insertion's
// style-reference verification (N3a): a fragment may only reference
// a style the target document defines.
paragraph_style_ids : Array[String]
/// The cumulative annotation path budget, created BEFORE the joined
/// reads so every story part charges the shared allowance exactly once
/// (at scan time); the annotation index adopts the retained scans and
/// charges only its verification copies into the same counter. None in
/// tolerant mode, where the index still runs its own scans.
annotation_path_budget : AnnotationPathBudget?
}
///|
fn DocumentParts::build(
zip : ZipArchive,
external_files? : ExternalFileAccess = default_external_file_access(),
read_images? : Bool = true,
xml_budget? : @xml.XmlReadBudget,
diagnostics? : ReaderDiagnosticCollector,
expected_main_document_path? : String,
strict_main_document? : Bool = false,
story_reader_mode? : StoryReaderMode = TolerantReader,
) -> DocumentParts raise DocxError {
let main_document = find_main_document(
zip,
xml_budget?,
expected_main_document_path?,
)
let main_document_path = main_document.path
let relationships = read_relationships(
zip,
relationships_path_for_part(zip, main_document_path),
xml_budget?,
)
let relationship_index = RelationshipIndex::build(relationships)
let content_types = read_content_types(zip, xml_budget?)
let logical_main_document_path = zip
.logical_path(main_document_path)
.unwrap_or(main_document_path)
let base_path = split_zip_path(logical_main_document_path).0
let comments_part_path = resolve_unique_internal_part(
zip,
relationships,
[TRANSITIONAL_COMMENTS_RELATIONSHIP, STRICT_COMMENTS_RELATIONSHIP],
base_path,
"comments",
reject_multiple_relationships=true,
)
let comments_extended_part_path = resolve_unique_internal_part(
zip,
relationships,
[COMMENTS_EXTENDED_RELATIONSHIP],
base_path,
"commentsExtended",
reject_multiple_relationships=true,
)
let footnotes_part_path = resolve_unique_internal_part(
zip,
relationships,
[TRANSITIONAL_FOOTNOTES_RELATIONSHIP, STRICT_FOOTNOTES_RELATIONSHIP],
base_path,
"footnotes",
reject_multiple_relationships=true,
)
let endnotes_part_path = resolve_unique_internal_part(
zip,
relationships,
[TRANSITIONAL_ENDNOTES_RELATIONSHIP, STRICT_ENDNOTES_RELATIONSHIP],
base_path,
"endnotes",
reject_multiple_relationships=true,
)
content_types.require_annotation_part(zip, comments_part_path, "comments")
content_types.require_annotation_part(
zip, comments_extended_part_path, "commentsExtended",
)
content_types.require_annotation_part(zip, footnotes_part_path, "footnotes")
content_types.require_annotation_part(zip, endnotes_part_path, "endnotes")
let styles_part = resolve_unique_wordprocessing_part(
zip,
relationships,
base_path,
StylesPartRole,
main_document.relationship_dialect,
)
let numbering_part = resolve_unique_wordprocessing_part(
zip,
relationships,
base_path,
NumberingPartRole,
main_document.relationship_dialect,
)
let styles = match styles_part {
Some(part) => {
content_types.require_wordprocessing_part(zip, part, StylesPartRole)
read_styles_xml(
read_wordprocessing_role_xml_part(
zip,
part,
StylesPartRole,
xml_budget?,
),
)
}
None => SortedMap([])
}
let paragraph_style_ids : Array[String] = []
for key, _info in styles {
if key.has_prefix("paragraph:") {
paragraph_style_ids.push(
key.substring(start=10, end=key.length()).to_string(),
)
}
}
let numbering = match numbering_part {
Some(part) => {
content_types.require_wordprocessing_part(zip, part, NumberingPartRole)
read_numbering_xml(
read_wordprocessing_role_xml_part(
zip,
part,
NumberingPartRole,
xml_budget?,
),
styles,
)
}
None => NumberingMap::empty()
}
let reader_projections : StableStringMap[ReaderProjection] = SortedMap([])
let reader_projection_sources : StableStringMap[BytesView] = SortedMap([])
let reader_paragraph_provenance : StableStringMap[
Array[Array[SourceElementId?]],
] = SortedMap([])
let annotation_path_budget = if story_reader_mode is JoinedMutationReader {
Some(AnnotationPathBudget::new(xml_budget))
} else {
None
}
let notes_result = read_notes_for_document(
zip,
styles,
numbering,
content_types,
footnotes_path=footnotes_part_path.unwrap_or("word/footnotes.xml"),
endnotes_path=endnotes_part_path.unwrap_or("word/endnotes.xml"),
story_reader_mode~,
reader_projections~,
reader_projection_sources~,
external_files~,
read_images~,
xml_budget?,
annotation_path_budget?,
diagnostics?,
)
let comments_result = read_comments_for_document(
zip,
styles,
numbering,
content_types,
relationships,
base_path,
story_reader_mode~,
reader_projections~,
reader_projection_sources~,
external_files~,
read_images~,
xml_budget?,
annotation_path_budget?,
comments_part_path?,
diagnostics?,
)
// Every public package projection uses the tolerant reader semantics.
// Mutation-safe annotated reads validate the same source again through the
// strict identity gate before exposing byte spans, without substituting the
// strict parser's normalized DOM into the returned document model.
let root = read_xml_part(zip, main_document_path, xml_budget?)
if strict_main_document {
validate_xml_part_strict_locally(zip, main_document_path)
}
{
zip,
xml_budget,
diagnostics,
annotation_path_budget,
main_document_path,
main_relationship_dialect: main_document.relationship_dialect,
root,
styles,
numbering,
content_types,
relationships,
relationship_index,
base_path,
notes_result,
comments_result,
comments_part_path,
comments_extended_part_path,
footnotes_part_path,
endnotes_part_path,
external_files,
read_images,
story_reader_mode,
reader_projections,
reader_projection_sources,
reader_paragraph_provenance,
paragraph_style_ids,
}
}
///|
fn DocumentParts::read_document(
self : DocumentParts,
) -> DocxReadResult raise DocxError {
match self.story_reader_mode {
TolerantReader =>
read_document_xml_with_messages(
self.root,
self.styles,
self.numbering,
self.zip,
self.relationship_index,
self.content_types,
self.base_path,
self.notes_result.notes,
self.comments_result.comments,
external_files=self.external_files,
read_images=self.read_images,
diagnostics?=self.diagnostics,
)
JoinedMutationReader => self.read_document_joined()
}
}
///|
/// The mutation-safe main-document read: ONE authoritative joined read
/// produces both the erased AST and the retained projection. The strict
/// gate already ran during build and `self.root` is the tolerant DOM,
/// so the join adds only the scan and the pairing.
fn DocumentParts::read_document_joined(
self : DocumentParts,
) -> DocxReadResult raise DocxError {
guard self.zip.read_bytes(self.main_document_path) is Some(bytes) else {
raise MissingPart(message="missing DOCX part: " + self.main_document_path)
}
let reader = self.story_body_reader()
let read = read_joined_story_prepared(
self.main_document_path,
bytes,
self.root,
reader,
path_budget?=self.annotation_path_budget,
)
self.reader_projections[self.main_document_path] = read.projection
self.reader_projection_sources[self.main_document_path] = bytes
guard read.output.segments is [segment] else {
raise Unsupported(
message="mutation-safe main-document read produced an unexpected segment count",
)
}
// The erase-order provenance sidecar, captured from the SAME items the
// tree below is erased from — the paraId join's tree half.
self.reader_paragraph_provenance[self.main_document_path] = collect_paragraph_provenance(
segment.items,
)
{
document: document(
reader_items_to_elements(segment.items),
notes=self.notes_result.notes,
comments=self.comments_result.comments,
),
messages: reader.messages,
}
}
///|
/// A body reader bound to the MAIN part's relationships, matching
/// `read_document_xml_with_messages`' construction exactly.
fn DocumentParts::story_body_reader(self : DocumentParts) -> BodyReader {
{
styles: self.styles,
zip: self.zip,
relationship_index: self.relationship_index,
content_types: self.content_types,
base_path: self.base_path,
numbering: self.numbering,
messages: [],
diagnostics: self.diagnostics,
external_files: self.external_files,
read_images: self.read_images,
}
}
///|
fn read_docx_zip_with_messages(
zip : ZipArchive,
external_files? : ExternalFileAccess = default_external_file_access(),
read_images? : Bool = true,
) -> DocxReadResult raise DocxError {
let parts = DocumentParts::build(zip, external_files~, read_images~)
let document_result = parts.read_document()
let messages : Array[Message] = []
messages.append(parts.notes_result.messages)
messages.append(parts.comments_result.messages)
messages.append(document_result.messages)
{
document: document_result.document,
messages: @core.dedupe_messages(messages),
}
}
///|
fn read_xml_part(
zip : ZipArchive,
path : String,
xml_budget? : @xml.XmlReadBudget,
) -> XmlElement raise DocxError {
read_xml_part_with_root_namespace_uri(zip, path, xml_budget?).1
}
///|
/// Reads one tolerant XML part while retaining the namespace identity of its
/// document element. The DOM's canonical name alone cannot distinguish a
/// bound QName from an identical literal name whose prefix is unbound.
fn read_xml_part_with_root_namespace_uri(
zip : ZipArchive,
path : String,
xml_budget? : @xml.XmlReadBudget,
) -> (String?, XmlElement) raise DocxError {
match zip.read_bytes(path) {
Some(bytes) =>
match xml_budget {
Some(budget) =>
@xml.read_xml_bytes_limited_with_root_namespace_uri(
bytes,
budget,
namespace_map=office_namespace_map(),
)
None => {
let text = @utf8.decode(bytes, ignore_bom=true) catch {
_ => raise InvalidXml(message="part is not valid UTF-8: " + path)
}
@xml.read_xml_string_with_root_namespace_uri(
text,
namespace_map=office_namespace_map(),
)
}
}
None => raise MissingPart(message="missing DOCX part: " + path)
}
}
///|
/// Strictly validates the mutation-safe main story without charging its source
/// twice to the caller's cumulative retained-DOM budget. The tolerant DOM was
/// already charged by `read_xml_part`; this discarded validation DOM receives
/// an input-linear transient budget of its own.
fn validate_xml_part_strict_locally(
zip : ZipArchive,
path : String,
) -> Unit raise DocxError {
guard zip.read_bytes(path) is Some(bytes) else {
raise MissingPart(message="missing DOCX part: " + path)
}
@xml.read_xml_bytes_strict_limited(
bytes,
local_input_linear_xml_budget(bytes),
namespace_map=office_namespace_map(),
)
|> ignore
}
///|
fn office_namespace_map() -> Map[String, String] {
{
"http://schemas.openxmlformats.org/wordprocessingml/2006/main": "w",
"http://purl.oclc.org/ooxml/wordprocessingml/main": "w",
"http://schemas.openxmlformats.org/officeDocument/2006/relationships": "r",
"http://purl.oclc.org/ooxml/officeDocument/relationships": "r",
"http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing": "wp",
"http://purl.oclc.org/ooxml/drawingml/main": "a",
"http://purl.oclc.org/ooxml/drawingml/picture": "pic",
"http://schemas.openxmlformats.org/package/2006/relationships": "relationships",
"http://schemas.openxmlformats.org/package/2006/content-types": "content-types",
"http://schemas.openxmlformats.org/markup-compatibility/2006": "mc",
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing": "wp",
"http://schemas.openxmlformats.org/drawingml/2006/main": "a",
"http://schemas.openxmlformats.org/drawingml/2006/picture": "pic",
"http://schemas.microsoft.com/office/word/2010/wordml": "w14",
"http://schemas.microsoft.com/office/word/2012/wordml": "w15",
"urn:schemas-microsoft-com:office:office": "o",
"urn:schemas-microsoft-com:office:word": "office-word",
"urn:schemas-microsoft-com:vml": "v",
}
}
///|
fn wordprocessing_role_namespace_map(
dialect : OoxmlDialect,
) -> Map[String, String] {
let namespaces = office_namespace_map()
match dialect {
TransitionalOoxml =>
namespaces[STRICT_WORDPROCESSINGML_NAMESPACE] = "w-incompatible"
StrictOoxml => namespaces[WORDPROCESSINGML_NAMESPACE] = "w-incompatible"
}
namespaces
}
///|
fn read_wordprocessing_role_xml_part(
zip : ZipArchive,
part : RelatedWordprocessingPart,
role : WordprocessingPartRole,
xml_budget? : @xml.XmlReadBudget,
) -> XmlElement raise DocxError {
guard zip.read_bytes(part.path) is Some(bytes) else {
raise MissingPart(message="missing DOCX part: " + part.path)
}
let namespace_map = wordprocessing_role_namespace_map(
part.relationship_dialect,
)
let root = match xml_budget {
Some(budget) =>
@xml.read_xml_bytes_strict_limited(bytes, budget, namespace_map~)
None => {
let text = @utf8.decode(bytes, ignore_bom=true) catch {
_ => raise InvalidXml(message="part is not valid UTF-8: " + part.path)
}
@xml.read_xml_string_strict(text, namespace_map~)
}
}
let kind = role.label()
let expected_root = "w:" + kind
if root.name != expected_root {
raise Unsupported(
message="the \{kind} relationship target '\{part.path}' has root '\{root.name}', expected '\{expected_root}' in the relationship dialect",
)
}
root
}
///|
priv struct MainDocumentPart {
path : String
relationship_dialect : OoxmlDialect?
}
///|
fn find_main_document(
zip : ZipArchive,
xml_budget? : @xml.XmlReadBudget,
expected_main_document_path? : String,
) -> MainDocumentPart raise DocxError {
let relationships = read_relationships(zip, "_rels/.rels", xml_budget?)
let path = resolve_unique_internal_part(
zip,
relationships,
[
TRANSITIONAL_OFFICE_DOCUMENT_RELATIONSHIP,
STRICT_OFFICE_DOCUMENT_RELATIONSHIP,
],
"",
"officeDocument",
reject_multiple_relationships=true,
)
fn authoritative_part(authoritative : String) -> MainDocumentPart {
let mut relationship_dialect : OoxmlDialect? = None
for relationship in relationships.relationships {
match office_relationship_dialect(relationship.relationship_type) {
Some(dialect) => relationship_dialect = Some(dialect)
None => ()
}
}
{ path: authoritative, relationship_dialect, }
}
match expected_main_document_path {
Some(expected) =>
match path {
Some(authoritative) if authoritative == expected =>
authoritative_part(authoritative)
Some(_) =>
raise Unsupported(
message="the DOCX reader selected a different main document part than structural validation",
)
None =>
raise Unsupported(
message="the structurally validated officeDocument relationship was not recognized by the DOCX reader",
)
}
None =>
match path {
Some(authoritative) => authoritative_part(authoritative)
None =>
match zip.resolve_path("word/document.xml") {
Some(actual) => { path: actual, relationship_dialect: None, }
None =>
raise MissingPart(
message="Could not find main document part. Are you sure this is a valid .docx file?",
)
}
}
}
}
///|
fn strip_leading_slash(path : String) -> String {
if path.has_prefix("/") {
path[1:].to_owned()
} else {
path
}
}
///|
fn read_relationships(
zip : ZipArchive,
path : String,
xml_budget? : @xml.XmlReadBudget,
) -> Relationships raise DocxError {
match zip.read_bytes(path) {
Some(bytes) => decode_relationships_bytes(bytes, xml_budget?)
None => { relationships: [], }
}
}
///|
fn decode_relationships_bytes(
bytes : BytesView,
xml_budget? : @xml.XmlReadBudget,
) -> Relationships raise DocxError {
let budget = match xml_budget {
Some(value) => value
None => local_input_linear_xml_budget(bytes)
}
read_relationships_xml(@opc.read_opc_relationships_xml_limited(bytes, budget))
}
///|
/// Strict DTD-free relationship read for security-sensitive identity walks.
/// The caller-owned budget is cumulative across the relationship graph and all
/// subsequently visited story parts.
fn read_relationships_strict_limited(
zip : ZipArchive,
path : String,
xml_budget : @xml.XmlReadBudget,
) -> Relationships raise DocxError {
match zip.read_bytes(path) {
Some(bytes) => decode_relationships_bytes(bytes, xml_budget~)
None => { relationships: [], }
}
}
///|
fn read_relationships_xml(root : XmlElement) -> Relationships {
let relationships : Array[Relationship] = []
for node in root.children {
guard node is XmlElement(rel) &&
(
rel.name == PACKAGE_RELATIONSHIP_ELEMENT ||
rel.name == "relationships:Relationship"
) else {
continue
}
relationships.push({
id: @xml.collapse_xml_schema_whitespace(
rel.attributes.get_or_default("Id", ""),
),
relationship_type: @xml.collapse_xml_schema_whitespace(
rel.attributes.get_or_default("Type", ""),
),
target: @xml.collapse_xml_schema_whitespace(
rel.attributes.get_or_default("Target", ""),
),
target_mode: rel.attributes.get("TargetMode"),
})
}
{ relationships, }
}
///|
/// Gives otherwise-unbounded library reads an input-linear strict XML budget.
/// Namespace expansion can materialize substantially more text than the
/// compact source, so derived work receives a conservative constant factor.
fn local_input_linear_xml_budget(bytes : BytesView) -> @xml.XmlReadBudget {
let scaled = bytes.length().to_int64() * 32L
let allowance = if scaled <= 0L {
1
} else if scaled > 2147483647L {
2147483647
} else {
scaled.to_int()
}
@xml.xml_read_budget(
max_source_units=bytes.length(),
max_tokens=allowance,
max_materialized_chars=allowance,
max_token_chars=if bytes.length() > 0 { bytes.length() } else { 1 },
)
}
///|
fn Relationships::find_targets_by_type(
self : Relationships,
relationship_type : String,
) -> Array[String] {
let targets : Array[String] = []
for rel in self.relationships {
if rel.relationship_type == relationship_type {
targets.push(rel.target)
}
}
targets
}
///|
/// Resolves one authoritative internal relationship target. Distinct targets,
/// external/invalid modes, empty targets, and missing declared targets fail
/// closed. Callers may apply a legacy filename fallback only when this returns
/// `None`, which means no matching relationship was declared at all.
fn resolve_unique_internal_part(
zip : ZipArchive,
relationships : Relationships,
relationship_types : ReadOnlyArray[String],
base_path : String,
label : String,
reject_multiple_relationships? : Bool = false,
) -> String? raise DocxError {
let targets : Array[String] = []
let seen : StableStringSet = SortedSet([])
let mut matches = 0
for relationship in relationships.relationships {
if relationship_types.search(relationship.relationship_type) is None {
continue
}
matches += 1
match relationship.target_mode {
None | Some("Internal") => ()
_ =>
raise Unsupported(message="the \{label} relationship must be internal")
}
if relationship.target == "" {
raise Unsupported(message="the \{label} relationship has no target")
}
guard @opc.resolve_part_target(base_path, relationship.target)
is Some(resolved) else {
raise Unsupported(message="the \{label} relationship target is invalid")
}
guard zip.resolve_path(resolved) is Some(actual) else {
raise MissingPart(
message="the \{label} relationship targets a missing part",
)
}
if !seen.contains(actual) {
seen.add(actual)
targets.push(actual)
}
}
match targets {
[] => None
[target] =>
if reject_multiple_relationships && matches > 1 {
raise Unsupported(
message="the document declares multiple \{label} relationships",
)
} else {
Some(target)
}
_ =>
raise Unsupported(
message="the document declares multiple distinct \{label} relationship targets",
)
}
}
///|
fn wordprocessing_role_relationship_identity(
relationship_type : String,
) -> (WordprocessingPartRole, OoxmlDialect)? {
match relationship_type {
TRANSITIONAL_STYLES_RELATIONSHIP =>
Some((StylesPartRole, TransitionalOoxml))
STRICT_STYLES_RELATIONSHIP => Some((StylesPartRole, StrictOoxml))
TRANSITIONAL_NUMBERING_RELATIONSHIP =>
Some((NumberingPartRole, TransitionalOoxml))
STRICT_NUMBERING_RELATIONSHIP => Some((NumberingPartRole, StrictOoxml))
_ => None
}
}
///|
/// Styles and numbering are optional relationship roles, not conventional
/// filenames. A declared role must match the main package dialect and resolve
/// to exactly one internal part before any XML from that target is consumed.
fn resolve_unique_wordprocessing_part(
zip : ZipArchive,
relationships : Relationships,
base_path : String,
role : WordprocessingPartRole,
main_dialect : OoxmlDialect?,
) -> RelatedWordprocessingPart? raise DocxError {
let kind = role.label()
let parts : Array[RelatedWordprocessingPart] = []
let seen : StableStringSet = SortedSet([])
let mut matches = 0
for relationship in relationships.relationships {
guard wordprocessing_role_relationship_identity(
relationship.relationship_type,
)
is Some((relationship_kind, relationship_dialect)) else {
continue
}
if !same_wordprocessing_part_role(relationship_kind, role) {
continue
}
match main_dialect {
Some(expected) if !same_ooxml_dialect(expected, relationship_dialect) =>
raise Unsupported(
message="the \{kind} relationship dialect does not match the main document relationship",
)
_ => ()
}
matches += 1
match relationship.target_mode {
None | Some("Internal") => ()
_ =>
raise Unsupported(message="the \{kind} relationship must be internal")
}
if relationship.target == "" {
raise Unsupported(message="the \{kind} relationship has no target")
}
guard @opc.resolve_part_target(base_path, relationship.target)
is Some(resolved) else {
raise Unsupported(message="the \{kind} relationship target is invalid")
}
guard zip.resolve_path(resolved) is Some(actual) else {
raise MissingPart(
message="the \{kind} relationship targets a missing part",
)
}
if !seen.contains(actual) {
seen.add(actual)
parts.push({ path: actual, relationship_dialect, })
}
}
if matches > 1 {
raise Unsupported(
message="the document declares multiple \{kind} relationships",
)
}
match parts {
[] => None
[part] => Some(part)
_ =>
raise Unsupported(
message="the document declares multiple distinct \{kind} relationship targets",
)
}
}
///|
fn annotation_story_relationship_identity(
relationship_type : String,
) -> (String, OoxmlDialect)? {
match relationship_type {
TRANSITIONAL_COMMENTS_RELATIONSHIP => Some(("comments", TransitionalOoxml))
STRICT_COMMENTS_RELATIONSHIP => Some(("comments", StrictOoxml))
TRANSITIONAL_HEADER_RELATIONSHIP => Some(("header", TransitionalOoxml))
STRICT_HEADER_RELATIONSHIP => Some(("header", StrictOoxml))
TRANSITIONAL_FOOTER_RELATIONSHIP => Some(("footer", TransitionalOoxml))
STRICT_FOOTER_RELATIONSHIP => Some(("footer", StrictOoxml))
TRANSITIONAL_FOOTNOTES_RELATIONSHIP =>
Some(("footnotes", TransitionalOoxml))
STRICT_FOOTNOTES_RELATIONSHIP => Some(("footnotes", StrictOoxml))
TRANSITIONAL_ENDNOTES_RELATIONSHIP => Some(("endnotes", TransitionalOoxml))
STRICT_ENDNOTES_RELATIONSHIP => Some(("endnotes", StrictOoxml))
_ => None
}
}
///|
/// Resolves every annotation-bearing main-part relationship authoritatively.
/// External, empty, missing, or cross-kind aliasing targets fail closed; same-
/// kind aliases that normalize to one OPC path are deduplicated.
fn resolve_reachable_annotation_story_parts(
zip : ZipArchive,
relationships : Relationships,
base_path : String,
) -> Array[ReachableAnnotationStoryPart] raise DocxError {
let resolved_parts : Array[ReachableAnnotationStoryPart] = []
let kind_by_path : StableStringMap[String] = SortedMap([])
let dialect_by_path : StableStringMap[OoxmlDialect] = SortedMap([])
for relationship in relationships.relationships {
guard annotation_story_relationship_identity(relationship.relationship_type)
is Some((kind, relationship_dialect)) else {
continue
}
match relationship.target_mode {
None | Some("Internal") => ()
_ =>
raise Unsupported(message="the \{kind} relationship must be internal")
}
if relationship.target == "" {
raise Unsupported(message="the \{kind} relationship has no target")
}
guard @opc.resolve_part_target(base_path, relationship.target) is Some(path) else {
raise Unsupported(message="the \{kind} relationship target is invalid")
}
guard zip.resolve_path(path) is Some(actual) else {
raise MissingPart(
message="the \{kind} relationship targets missing part '\{path}'",
)
}
match kind_by_path.get(actual) {
Some(existing_kind) if existing_kind != kind =>
raise Unsupported(
message="annotation story part '\{actual}' is targeted as both \{existing_kind} and \{kind}",
)
Some(_) => {
guard dialect_by_path.get(actual) is Some(existing_dialect) else {
raise Unsupported(
message="the \{kind} relationship dialect could not be determined",
)
}
if !same_ooxml_dialect(existing_dialect, relationship_dialect) {
raise Unsupported(
message="annotation story part '\{actual}' has conflicting Strict and Transitional \{kind} relationships",
)
}
continue
}
None => ()
}
kind_by_path[actual] = kind
dialect_by_path[actual] = relationship_dialect
resolved_parts.push({ kind, path: actual, relationship_dialect, })
}
resolved_parts
}
///|
/// Resolves a section's header/footer reference through exactly one internal
/// relationship of the matching semantic type. This is shared by the tolerant
/// package projection and the strict annotation-identity gate so the two paths
/// cannot disagree about what a section reference means.
fn resolve_header_footer_reference_part(
zip : ZipArchive,
relationship_index : RelationshipIndex,
base_path : String,
id : String,
is_header : Bool,
) -> String raise DocxError {
let kind = if is_header { "header" } else { "footer" }
guard relationship_index.by_id.get(id) is Some(matches) else {
raise Unsupported(
message="the \{kind} reference names unknown relationship id '\{id}'",
)
}
if matches.length() != 1 {
raise Unsupported(
message="the \{kind} reference names duplicate relationship id '\{id}'",
)
}
let relationship = matches[0]
let type_matches = if is_header {
relationship.relationship_type == TRANSITIONAL_HEADER_RELATIONSHIP ||
relationship.relationship_type == STRICT_HEADER_RELATIONSHIP
} else {
relationship.relationship_type == TRANSITIONAL_FOOTER_RELATIONSHIP ||
relationship.relationship_type == STRICT_FOOTER_RELATIONSHIP
}
if !type_matches {
raise Unsupported(
message="the \{kind} reference relationship '\{id}' is not a \{kind} relationship",
)
}
match relationship.target_mode {
None | Some("Internal") => ()
_ =>
raise Unsupported(
message="the \{kind} reference relationship '\{id}' must be internal",
)
}
if relationship.target == "" {
raise Unsupported(
message="the \{kind} reference relationship '\{id}' has no target",
)
}
guard @opc.resolve_part_target(base_path, relationship.target) is Some(path) else {
raise Unsupported(
message="the \{kind} reference relationship '\{id}' has an invalid target",
)
}
match zip.resolve_path(path) {
Some(actual) => actual
None =>
raise MissingPart(
message="the \{kind} reference relationship '\{id}' targets missing part '\{path}'",
)
}
}
///|
fn relationships_path_for_part(zip : ZipArchive, path : String) -> String {
let logical_path = zip.logical_path(path).unwrap_or(path)
let (dir, base) = split_zip_path(logical_path)
join_zip_path([dir, "_rels", base + ".rels"])
}
///|
fn read_content_types(
zip : ZipArchive,
xml_budget? : @xml.XmlReadBudget,
) -> ContentTypes raise DocxError {
let defaults : StableStringMap[String] = SortedMap([])
let overrides : StableStringMap[String] = SortedMap([])
match zip.read_bytes("[Content_Types].xml") {
Some(_) => {
let root = read_xml_part(zip, "[Content_Types].xml", xml_budget?)
for element in root.elements_by_tag_name("content-types:Default") {
let extension = element.attributes.get_or_default("Extension", "")
let content_type = element.attributes.get_or_default("ContentType", "")
if extension != "" && content_type != "" {
defaults[@opc.part_name_key(extension)] = content_type
}
}
for element in root.elements_by_tag_name("content-types:Override") {
let part_name = element.attributes.get_or_default("PartName", "")
let content_type = element.attributes.get_or_default("ContentType", "")
if part_name != "" && content_type != "" {
match @opc.resolve_part_target("", part_name) {
Some(logical) if "/" + logical == part_name =>
overrides[@opc.part_name_key(logical)] = content_type
_ => ()
}
}
}
}
None => ()
}
{ defaults, overrides, }
}
///|
fn ContentTypes::find_content_type(
self : ContentTypes,
path : String,
zip? : ZipArchive,
) -> String {
let requested = strip_leading_slash(path)
let logical = match zip {
Some(archive) => archive.logical_path(requested).unwrap_or(requested)
None => requested
}
let normalized = @opc.part_name_key(logical)
match self.overrides.get(normalized) {
Some(content_type) => content_type
None =>
match normalized.rev_find(".") {
Some(index) => {
let extension = normalized[index + 1:].to_owned()
match self.defaults.get(extension) {
Some(content_type) => content_type
None => fallback_content_type(extension)
}
}
None => ""
}
}
}
///|
fn expected_annotation_content_type(kind : String) -> String? {
match kind {
"comments" => Some(COMMENTS_CONTENT_TYPE)
"commentsExtended" => Some(COMMENTS_EXTENDED_CONTENT_TYPE)
"footnotes" => Some(FOOTNOTES_CONTENT_TYPE)
"endnotes" => Some(ENDNOTES_CONTENT_TYPE)
"header" => Some(HEADER_CONTENT_TYPE)
"footer" => Some(FOOTER_CONTENT_TYPE)
_ => None
}
}
///|
/// Relationship roles, target existence, root QNames, and content types form one
/// identity contract. A generic XML default is not sufficient for an OOXML
/// annotation story even when the target's root element looks plausible.
fn ContentTypes::require_annotation_part(
self : ContentTypes,
zip : ZipArchive,
path : String?,
kind : String,
) -> Unit raise DocxError {
guard path is Some(path) else { return }
guard expected_annotation_content_type(kind) is Some(expected) else { return }
let actual = self.find_content_type(path, zip~)
if actual.to_lower() != expected.to_lower() {
let declared = if actual == "" { "" } else { actual }
raise Unsupported(
message="the \{kind} relationship target '\{path}' has content type '\{declared}', expected '\{expected}'",
)
}
}
///|
fn ContentTypes::require_wordprocessing_part(
self : ContentTypes,
zip : ZipArchive,
part : RelatedWordprocessingPart,
role : WordprocessingPartRole,
) -> Unit raise DocxError {
let kind = role.label()
let expected = role.content_type()
let actual = self.find_content_type(part.path, zip~)
if actual.to_lower() != expected.to_lower() {
let declared = if actual == "" { "" } else { actual }
raise Unsupported(
message="the \{kind} relationship target '\{part.path}' has content type '\{declared}', expected '\{expected}'",
)
}
}
///|
fn fallback_content_type(extension : String) -> String {
match extension.to_lower() {
"png" => "image/png"
"gif" => "image/gif"
"jpeg" | "jpg" => "image/jpeg"
"bmp" => "image/bmp"
"tif" | "tiff" => "image/tiff"
_ => ""
}
}
///|
fn read_numbering_xml(
root : XmlElement,
styles : StableStringMap[StyleInfo],
) -> NumberingMap {
let abstract_nums = read_abstract_numberings(root)
let nums = read_numbering_instances(root)
let numbering_style_num_ids = index_numbering_style_num_ids(styles)
{
abstract_nums,
levels_by_paragraph_style_id: index_levels_by_paragraph_style_id(
abstract_nums,
),
resolved_abstract_num_ids: resolve_numbering_style_links(
nums, abstract_nums, numbering_style_num_ids,
),
}
}
///|
fn read_abstract_numberings(
root : XmlElement,
) -> StableStringMap[AbstractNumbering] {
let abstract_nums : StableStringMap[AbstractNumbering] = SortedMap([])
for element in root.elements_by_tag_name("w:abstractNum") {
match element.attributes.get("w:abstractNumId") {
Some(id) => abstract_nums[id] = read_abstract_numbering(element)
None => ()
}
}
abstract_nums
}
///|
fn read_abstract_numbering(element : XmlElement) -> AbstractNumbering {
let levels : StableStringMap[NumberingLevel] = SortedMap([])
let mut level_without_index : NumberingLevel? = None
for level_element in element.elements_by_tag_name("w:lvl") {
let level = read_numbering_level(level_element)
match level_element.attributes.get("w:ilvl") {
Some(index) => levels[index] = level
None => level_without_index = Some(level)
}
}
match level_without_index {
Some(level) => if !levels.contains("0") { levels["0"] = level }
None => ()
}
{
levels,
num_style_link: element.first_or_empty("w:numStyleLink").attributes.get(
"w:val",
),
}
}
///|
fn read_numbering_level(element : XmlElement) -> NumberingLevel {
let level_index = @string.parse_int(
element.attributes.get_or_default("w:ilvl", "0"),
) catch {
_ => 0
}
let num_fmt = element.first_or_empty("w:numFmt").attributes.get("w:val")
{
is_ordered: num_fmt != Some("bullet"),
level: level_index + 1,
paragraph_style_id: element.first_or_empty("w:pStyle").attributes.get(
"w:val",
),
}
}
///|
fn read_numbering_instances(root : XmlElement) -> StableStringMap[String] {
let nums : StableStringMap[String] = SortedMap([])
for element in root.elements_by_tag_name("w:num") {
let num_id = element.attributes.get_or_default("w:numId", "")
let abstract_num_id = element.first_or_empty("w:abstractNumId").attributes.get_or_default(
"w:val", "",
)
if num_id != "" && abstract_num_id != "" {
nums[num_id] = abstract_num_id
}
}
nums
}
///|
fn index_levels_by_paragraph_style_id(
abstract_nums : StableStringMap[AbstractNumbering],
) -> StableStringMap[NumberingLevel] {
let levels : StableStringMap[NumberingLevel] = SortedMap([])
for item in abstract_nums.to_array() {
let (_, abstract_num) = item
for level_item in abstract_num.levels.to_array() {
let (_, level) = level_item
match level.paragraph_style_id {
Some(style_id) => levels[style_id] = level
None => ()
}
}
}
levels
}
///|
fn index_numbering_style_num_ids(
styles : StableStringMap[StyleInfo],
) -> StableStringMap[String] {
let style_num_ids : StableStringMap[String] = SortedMap([])
for item in styles.to_array() {
let (_, style) = item
if style.style_type == "numbering" {
if style.name != "" {
style_num_ids[style.style_id] = style.name
}
}
}
style_num_ids
}
///|
fn resolve_numbering_style_links(
nums : StableStringMap[String],
abstract_nums : StableStringMap[AbstractNumbering],
numbering_style_num_ids : StableStringMap[String],
) -> StableStringMap[NumberingLinkResolution] {
let resolved : StableStringMap[NumberingLinkResolution] = SortedMap([])
// Every successful loop iteration consumes one previously unresolved
// `` node and resolves the entire path on exit. This explicit budget
// pins total graph work to O(number of numbering instances), including
// cycles and links to missing ids.
let mut remaining = nums.length()
for start, _ in nums {
if resolved.contains(start) {
continue
}
let path : Array[String] = []
let path_ids : StableStringSet = SortedSet([])
let mut current = start
let mut resolution = UnresolvableNumbering
let mut done = false
while !done {
match resolved.get(current) {
Some(cached) => {
resolution = cached
done = true
}
None =>
if path_ids.contains(current) {
resolution = UnresolvableNumbering
done = true
} else {
match nums.get(current) {
None => {
resolution = UnresolvableNumbering
done = true
}
Some(abstract_num_id) => {
if remaining <= 0 {
resolution = UnresolvableNumbering
done = true
continue
}
remaining = remaining - 1
path.push(current)
path_ids.add(current)
match abstract_nums.get(abstract_num_id) {
None => {
resolution = UnresolvableNumbering
done = true
}
Some(abstract_num) =>
match abstract_num.num_style_link {
None => {
resolution = ResolvedAbstractNumbering(abstract_num_id)
done = true
}
Some(style_id) =>
match numbering_style_num_ids.get(style_id) {
Some(linked_num_id) => current = linked_num_id
None => {
resolution = UnresolvableNumbering
done = true
}
}
}
}
}
}
}
}
}
for num_id in path {
resolved[num_id] = resolution
}
}
resolved
}
///|
fn NumberingMap::find_level(
self : NumberingMap,
num_id : String,
level : String,
) -> Numbering? {
guard self.resolved_abstract_num_ids.get(num_id)
is Some(ResolvedAbstractNumbering(abstract_num_id)) else {
return None
}
guard self.abstract_nums.get(abstract_num_id) is Some(abstract_num) else {
return None
}
match abstract_num.levels.get(level) {
Some(numbering_level) => Some(numbering_level.to_numbering())
None => None
}
}
///|
fn NumberingMap::find_level_by_paragraph_style_id(
self : NumberingMap,
style_id : String,
) -> Numbering? {
match self.levels_by_paragraph_style_id.get(style_id) {
Some(level) => Some(level.to_numbering())
None => None
}
}
///|
fn NumberingLevel::to_numbering(self : NumberingLevel) -> Numbering {
{ is_ordered: self.is_ordered, level: self.level, }
}
///|
fn read_notes_for_document(
zip : ZipArchive,
styles : StableStringMap[StyleInfo],
numbering : NumberingMap,
content_types : ContentTypes,
footnotes_path~ : String,
endnotes_path~ : String,
story_reader_mode~ : StoryReaderMode,
reader_projections~ : StableStringMap[ReaderProjection],
reader_projection_sources~ : StableStringMap[BytesView],
external_files? : ExternalFileAccess = default_external_file_access(),
read_images? : Bool = true,
xml_budget? : @xml.XmlReadBudget,
annotation_path_budget? : AnnotationPathBudget,
diagnostics? : ReaderDiagnosticCollector,
) -> NotesReadResult raise DocxError {
let notes : Array[Note] = []
let annotation_ids : Array[String?] = []
let messages : Array[Message] = []
let footnotes = read_note_part_at_path(
zip,
note_type="footnote",
path=footnotes_path,
story_reader_mode~,
reader_projections~,
reader_projection_sources~,
styles~,
numbering~,
content_types~,
external_files~,
read_images~,
xml_budget?,
annotation_path_budget?,
diagnostics?,
)
notes.append(footnotes.notes)
annotation_ids.append(footnotes.annotation_ids)
messages.append(footnotes.messages)
let endnotes = read_note_part_at_path(
zip,
note_type="endnote",
path=endnotes_path,
story_reader_mode~,
reader_projections~,
reader_projection_sources~,
styles~,
numbering~,
content_types~,
external_files~,
read_images~,
xml_budget?,
annotation_path_budget?,
diagnostics?,
)
notes.append(endnotes.notes)
annotation_ids.append(endnotes.annotation_ids)
messages.append(endnotes.messages)
{ notes, annotation_ids, messages: @core.dedupe_messages(messages), }
}
///|
fn read_note_part_at_path(
zip : ZipArchive,
note_type~ : String,
path~ : String,
story_reader_mode~ : StoryReaderMode,
reader_projections~ : StableStringMap[ReaderProjection],
reader_projection_sources~ : StableStringMap[BytesView],
styles~ : StableStringMap[StyleInfo],
numbering~ : NumberingMap,
content_types~ : ContentTypes,
external_files? : ExternalFileAccess = default_external_file_access(),
read_images? : Bool = true,
xml_budget? : @xml.XmlReadBudget,
annotation_path_budget? : AnnotationPathBudget,
diagnostics? : ReaderDiagnosticCollector,
) -> NotesReadResult raise DocxError {
if !zip.exists(path) {
return { notes: [], annotation_ids: [], messages: [], }
}
let part_relationships = read_relationships(
zip,
relationships_path_for_part(zip, path),
xml_budget?,
)
let reader = BodyReader::{
styles,
zip,
relationship_index: RelationshipIndex::build(part_relationships),
content_types,
base_path: split_zip_path(zip.logical_path(path).unwrap_or(path)).0,
numbering,
messages: [],
diagnostics,
external_files,
read_images,
}
if story_reader_mode is JoinedMutationReader {
guard zip.read_bytes(path) is Some(bytes) else {
raise MissingPart(message="missing DOCX part: " + path)
}
let read = read_joined_story_bytes(
path,
bytes,
reader,
strict_budget?=xml_budget,
tolerant_budget?=xml_budget,
path_budget?=annotation_path_budget,
)
reader_projections[path] = read.projection
reader_projection_sources[path] = bytes
let notes : Array[Note] = []
let annotation_ids : Array[String?] = []
for segment in read.output.segments {
if segment.container.name() == "w:" + note_type {
let annotation_id = segment.container.element.attributes.get("w:id")
notes.push({
note_type,
note_id: annotation_id_or_missing(annotation_id),
body: reader_items_to_elements(segment.items),
})
annotation_ids.push(annotation_id)
}
}
return { notes, annotation_ids, messages: reader.messages, }
}
let root = read_xml_part(zip, path, xml_budget?)
let notes : Array[Note] = []
let annotation_ids : Array[String?] = []
for note_element in root.elements_by_tag_name("w:" + note_type) {
let note_kind = note_element.attributes.get("w:type")
// separator/continuationSeparator/continuationNotice are Word's
// plumbing notes (rule glyphs and "continued" notices), not user
// content — surfacing them would invent phantom notes.
if note_kind != Some("separator") &&
note_kind != Some("continuationSeparator") &&
note_kind != Some("continuationNotice") {
let annotation_id = note_element.attributes.get("w:id")
notes.push({
note_type,
note_id: annotation_id_or_missing(annotation_id),
body: reader_items_to_elements(
reader.read_children(ReaderNode::of(note_element).element_children()),
),
})
annotation_ids.push(annotation_id)
}
}
{ notes, annotation_ids, messages: reader.messages, }
}
///|
fn read_comments_for_document(
zip : ZipArchive,
styles : StableStringMap[StyleInfo],
numbering : NumberingMap,
content_types : ContentTypes,
document_relationships : Relationships,
base_path : String,
story_reader_mode~ : StoryReaderMode,
reader_projections~ : StableStringMap[ReaderProjection],
reader_projection_sources~ : StableStringMap[BytesView],
external_files? : ExternalFileAccess = default_external_file_access(),
read_images? : Bool = true,
xml_budget? : @xml.XmlReadBudget,
annotation_path_budget? : AnnotationPathBudget,
comments_part_path? : String,
diagnostics? : ReaderDiagnosticCollector,
) -> CommentsReadResult raise DocxError {
let authoritative = match comments_part_path {
Some(path) => Some(path)
None =>
resolve_unique_internal_part(
zip,
document_relationships,
[TRANSITIONAL_COMMENTS_RELATIONSHIP, STRICT_COMMENTS_RELATIONSHIP],
base_path,
"comments",
reject_multiple_relationships=true,
)
}
let path = authoritative.unwrap_or("word/comments.xml")
if !zip.exists(path) {
return { comments: [], annotation_ids: [], messages: [], }
}
let part_relationships = read_relationships(
zip,
relationships_path_for_part(zip, path),
xml_budget?,
)
let reader = BodyReader::{
styles,
zip,
relationship_index: RelationshipIndex::build(part_relationships),
content_types,
base_path: split_zip_path(zip.logical_path(path).unwrap_or(path)).0,
numbering,
messages: [],
diagnostics,
external_files,
read_images,
}
if story_reader_mode is JoinedMutationReader {
guard zip.read_bytes(path) is Some(bytes) else {
raise MissingPart(message="missing DOCX part: " + path)
}
let read = read_joined_story_bytes(
path,
bytes,
reader,
strict_budget?=xml_budget,
tolerant_budget?=xml_budget,
path_budget?=annotation_path_budget,
)
reader_projections[path] = read.projection
reader_projection_sources[path] = bytes
let comments : Array[Comment] = []
let annotation_ids : Array[String?] = []
for segment in read.output.segments {
if segment.container.name() == "w:comment" {
let annotation_id = segment.container.element.attributes.get("w:id")
comments.push({
comment_id: annotation_id_or_missing(annotation_id),
body: reader_items_to_elements(segment.items),
author_name: read_optional_attribute(
segment.container.element,
"w:author",
),
author_initials: read_optional_attribute(
segment.container.element,
"w:initials",
),
})
annotation_ids.push(annotation_id)
}
}
return { comments, annotation_ids, messages: reader.messages, }
}
let root = read_xml_part(zip, path, xml_budget?)
let comments : Array[Comment] = []
let annotation_ids : Array[String?] = []
for comment_element in root.elements_by_tag_name("w:comment") {
let annotation_id = comment_element.attributes.get("w:id")
comments.push({
comment_id: annotation_id_or_missing(annotation_id),
body: reader_items_to_elements(
reader.read_children(ReaderNode::of(comment_element).element_children()),
),
author_name: read_optional_attribute(comment_element, "w:author"),
author_initials: read_optional_attribute(comment_element, "w:initials"),
})
annotation_ids.push(annotation_id)
}
{ comments, annotation_ids, messages: reader.messages, }
}
///|
fn read_optional_attribute(element : XmlElement, name : String) -> String {
match element.attributes.get(name) {
Some(value) => {
let trimmed = value.trim()
if trimmed.is_empty() {
""
} else {
trimmed.to_owned()
}
}
None => ""
}
}
///|
fn read_styles_xml(root : XmlElement) -> StableStringMap[StyleInfo] {
let styles : StableStringMap[StyleInfo] = SortedMap([])
for style in root.elements_by_tag_name("w:style") {
let style_id = style.attributes.get_or_default("w:styleId", "")
if style_id != "" {
let style_info = read_style_info(style, style_id)
let key = style_lookup_key(style_info.style_type, style_id)
if !styles.contains(key) {
styles[key] = style_info
}
}
}
styles
}
///|
fn style_lookup_key(style_type : String, style_id : String) -> String {
style_type + ":" + style_id
}
///|
fn StableStringMap::find_style(
self : StableStringMap[StyleInfo],
style_type : String,
style_id : String,
) -> StyleInfo? {
match self.get(style_lookup_key(style_type, style_id)) {
Some(style) => Some(style)
None =>
match self.get(style_id) {
Some(style) =>
if style.style_type == style_type {
Some(style)
} else {
None
}
None => None
}
}
}
///|
fn read_style_info(element : XmlElement, style_id : String) -> StyleInfo {
let style_type = element.attributes.get_or_default("w:type", "")
{
style_id,
style_type,
name: if style_type == "numbering" {
element
.first_or_empty("w:pPr")
.first_or_empty("w:numPr")
.first_or_empty("w:numId").attributes.get_or_default("w:val", "")
} else {
element.first_or_empty("w:name").attributes.get_or_default("w:val", "")
},
}
}
///|
fn read_document_xml_with_messages(
root : XmlElement,
styles : StableStringMap[StyleInfo],
numbering : NumberingMap,
zip : ZipArchive,
relationship_index : RelationshipIndex,
content_types : ContentTypes,
base_path : String,
notes : Array[Note],
comments : Array[Comment],
external_files? : ExternalFileAccess = default_external_file_access(),
read_images? : Bool = true,
diagnostics? : ReaderDiagnosticCollector,
) -> DocxReadResult raise DocxError {
let body = match root.first("w:body") {
Some(body) => body
None =>
raise InvalidXml(
message="Could not find the body element: are you sure this is a docx file?",
)
}
let reader = BodyReader::{
styles,
zip,
relationship_index,
content_types,
base_path,
numbering,
messages: [],
diagnostics,
external_files,
read_images,
}
{
document: document(
reader_items_to_elements(
reader.read_children(ReaderNode::of(body).element_children()),
),
notes~,
comments~,
),
messages: reader.messages,
}
}
///|
priv struct BodyReader {
styles : StableStringMap[StyleInfo]
zip : ZipArchive
relationship_index : RelationshipIndex
content_types : ContentTypes
base_path : String
numbering : NumberingMap
messages : Array[Message]
diagnostics : ReaderDiagnosticCollector?
external_files : ExternalFileAccess
read_images : Bool
}
///|
fn BodyReader::read_children(
self : BodyReader,
children : Array[ReaderNode],
) -> Array[ReaderItem] raise DocxError {
let items : Array[ReaderItem] = []
let deleted_paragraphs : Array[ReaderNode] = []
// each pending deleted paragraph remembers where in the output its
// suppression trace belongs, in case no paragraph ever joins it
let deleted_paragraph_pending : Array[(ReaderNode, Int)] = []
for element in children {
match element.name() {
"w:p" if is_deleted_paragraph(element) => {
deleted_paragraphs.push(element)
deleted_paragraph_pending.push((element, items.length()))
}
"w:p" if !deleted_paragraphs.is_empty() => {
// deleted-paragraph join: the joined paragraph records every source
// paragraph with its own carriers and reads the prefix children
// natively
items.push(
self.read_paragraph_elements_with_prefix(
element,
deleted_paragraphs.copy(),
),
)
deleted_paragraphs.clear()
deleted_paragraph_pending.clear()
}
_ => items.append(self.read_element(element))
}
}
// deleted paragraphs with no following paragraph to join never
// contribute; their traces are inserted back at document order (largest
// index first so earlier positions stay valid)
for index = deleted_paragraph_pending.length() - 1
index >= 0
index = index - 1 {
let (node, at) = deleted_paragraph_pending[index]
items.insert(at, Trace(Suppressed(source=node)))
}
items
}
///|
fn BodyReader::read_element(
self : BodyReader,
element : ReaderNode,
) -> Array[ReaderItem] raise DocxError {
match element.name() {
"w:p" =>
if is_deleted_paragraph(element) {
[Trace(Suppressed(source=element))]
} else {
[self.read_paragraph_elements(element)]
}
"w:r" => [self.read_run(element)]
"w:t" => {
let value = element.text()
[
New({
source: element,
local_effects: [ProjectsText(source=element, kind=FromText, value~)],
shape: ReaderText(value),
}),
]
}
"w:tab" =>
[
New({
source: element,
local_effects: [
ProjectsText(source=element, kind=FromTab, value="\t"),
],
shape: ReaderTab,
}),
]
"w:noBreakHyphen" =>
[
New({
source: element,
local_effects: [
ProjectsText(
source=element,
kind=FromNoBreakHyphen,
value="\u{2011}",
),
],
shape: ReaderText("\u{2011}"),
}),
]
"w:softHyphen" =>
[
New({
source: element,
local_effects: [
ProjectsText(source=element, kind=FromSoftHyphen, value="\u{00AD}"),
],
shape: ReaderText("\u{00AD}"),
}),
]
"w:sym" => self.read_symbol(element)
"w:footnoteReference" =>
[
New({
source: element,
local_effects: [ProjectsVisibleNonText(source=element)],
shape: ReaderNoteReference(
note_type="footnote",
note_id=annotation_id_or_missing(element.attribute("w:id")),
),
}),
]
"w:endnoteReference" =>
[
New({
source: element,
local_effects: [ProjectsVisibleNonText(source=element)],
shape: ReaderNoteReference(
note_type="endnote",
note_id=annotation_id_or_missing(element.attribute("w:id")),
),
}),
]
"w:footnoteRef" | "w:endnoteRef" => [Trace(Suppressed(source=element))]
"w:commentReference" =>
[
New({
source: element,
local_effects: [ProjectsVisibleNonText(source=element)],
shape: ReaderCommentReference(
annotation_id_or_missing(element.attribute("w:id")),
),
}),
]
"w:br" => self.read_break(element)
"w:bookmarkStart" =>
match element.attribute("w:name") {
Some("_GoBack") => [Trace(TransparentBoundary(source=element))]
None =>
[
New({
source: element,
local_effects: [TransparentBoundary(source=element)],
shape: ReaderBookmarkStart("undefined"),
}),
]
Some(name) =>
[
New({
source: element,
local_effects: [TransparentBoundary(source=element)],
shape: ReaderBookmarkStart(name),
}),
]
}
"w:hyperlink" => self.read_hyperlink(element)
"w:drawing" | "w:object" =>
reader_transparent_items(
element,
self.read_children(element.element_children()),
)
"w:pict" => [Trace(Suppressed(source=element))]
"mc:AlternateContent" =>
self.read_alternate_content_items(element, child => {
self.read_children(child.element_children())
})
"wp:inline" | "wp:anchor" => self.read_drawing_element(element)
"v:roundrect" | "v:shape" | "v:textbox" | "v:group" | "v:rect" =>
reader_transparent_items(
element,
self.read_children(element.element_children()),
)
"v:imagedata" => self.read_vml_image_data(element)
"w:tbl" => [self.read_table(element)]
"w:tr" => self.read_table_row(element)
"w:tc" => [self.read_table_cell(element)]
"w:ins" | "w:smartTag" | "w:sdtContent" =>
reader_transparent_items(
element,
self.read_children(element.element_children()),
)
"w:sdt" => self.read_structured_document_tag(element)
_ =>
if is_suppressed_body_element(element.name()) {
[Trace(Suppressed(source=element))]
} else if is_ignored_body_element(element.name()) {
[Trace(TransparentBoundary(source=element))]
} else {
self.warn_parts([
"An unrecognised element was ignored: ",
element.name(),
])
[Trace(Suppressed(source=element))]
}
}
}
///|
/// One `mc:AlternateContent` selection with its effects in document order:
/// every child the reader passes over is a rejection recorded where it
/// sits, and only the FIRST fallback contributes (matching the
/// `first_or_empty` selection every caller used). Each dispatch site that
/// selects a fallback -- inline reading, the pict extras pass, the textbox
/// search -- reads it its own way.
fn BodyReader::read_alternate_content_items(
self : BodyReader,
element : ReaderNode,
read_fallback : (ReaderNode) -> Array[ReaderItem] raise DocxError,
) -> Array[ReaderItem] raise DocxError {
ignore(self)
let items : Array[ReaderItem] = [Trace(TransparentBoundary(source=element))]
let mut fallback_seen = false
for child in element.element_children() {
if child.name() == "mc:Fallback" && !fallback_seen {
fallback_seen = true
items.append(read_fallback(child))
} else {
items.push(Trace(Suppressed(source=child)))
}
}
items
}
///|
/// A transparent dispatch arm: the container contributes a zero-width
/// boundary and its children pass through.
fn reader_transparent_items(
element : ReaderNode,
children : Array[ReaderItem],
) -> Array[ReaderItem] {
let items : Array[ReaderItem] = [Trace(TransparentBoundary(source=element))]
items.append(children)
items
}
///|
fn BodyReader::warn(self : BodyReader, message : String) -> Unit {
push_reader_message(self.messages, self.diagnostics, Warning(message))
}
///|
fn BodyReader::warn_parts(self : BodyReader, parts : ArrayView[String]) -> Unit {
push_reader_warning_parts(self.messages, self.diagnostics, parts)
}
///|
fn BodyReader::error_parts(
self : BodyReader,
parts : ArrayView[String],
) -> Unit {
push_reader_error_parts(self.messages, self.diagnostics, parts)
}
///|
fn is_ignored_body_element(name : String) -> Bool {
name == "office-word:wrap" ||
name == "v:shadow" ||
name == "v:shapetype" ||
name == "w:annotationRef" ||
name == "w:bookmarkEnd" ||
name == "w:sectPr" ||
name == "w:proofErr" ||
name == "w:lastRenderedPageBreak" ||
name == "w:commentRangeStart" ||
name == "w:commentRangeEnd" ||
name == "w:fldChar" ||
name == "w:pPr" ||
name == "w:rPr" ||
name == "w:tblPr" ||
name == "w:tblGrid" ||
name == "w:trPr" ||
name == "w:tcPr"
}
///|
/// Ignored elements whose CONTENT this dispatch site drops, as opposed to
/// the zero-width markers and formatting metadata above. `Suppressed` records
/// the decision at the site alone: a `w:txbxContent` dropped here may still
/// be re-homed by the extras passes, whose items carry their own effects.
fn is_suppressed_body_element(name : String) -> Bool {
name == "w:del" ||
name == "w:footnoteRef" ||
name == "w:endnoteRef" ||
name == "w:instrText" ||
name == "w:txbxContent"
}
///|
fn BodyReader::read_symbol(
self : BodyReader,
element : ReaderNode,
) -> Array[ReaderItem] {
ignore(self)
let font_attribute = element.attribute("w:font")
let char_attribute = element.attribute("w:char")
let font = attribute_or_empty(font_attribute)
let char = attribute_or_empty(char_attribute)
match symbol_to_unicode(font, char) {
Some(value) =>
[
New({
source: element,
local_effects: [ProjectsText(source=element, kind=FromSymbol, value~)],
shape: ReaderText(value),
}),
]
None => {
self.warn_parts([
"A w:sym element with an unsupported character was ignored: char ",
attribute_or_js_undefined(char_attribute),
" in font ",
attribute_or_js_undefined(font_attribute),
])
[Trace(Suppressed(source=element))]
}
}
}
///|
fn attribute_or_empty(value : String?) -> String {
match value {
Some(value) => value
None => ""
}
}
///|
fn attribute_or_js_undefined(value : String?) -> String {
match value {
Some(value) => value
None => "undefined"
}
}
///|
fn symbol_to_unicode(font : String, char : String) -> String? {
match parse_dingbat_hex(char) {
Some(code) =>
match dingbat_to_unicode(font, code) {
Some(value) => Some(value)
None =>
if char.length() == 4 && char.has_prefix("F0") {
match parse_dingbat_hex(char[2:].to_owned()) {
Some(shifted_code) => dingbat_to_unicode(font, shifted_code)
None => None
}
} else {
None
}
}
None => None
}
}
///|
fn parse_dingbat_hex(value : String) -> Int? {
let mut index = 0
while index < value.length() && is_field_whitespace(value[index]) {
index += 1
}
let start = index
let mut result = 0
while index < value.length() {
match hex_value(value[index]) {
Some(digit) => {
result = result * 16 + digit
index += 1
}
None => break
}
}
if index == start {
None
} else {
Some(result)
}
}
///|
fn is_deleted_paragraph(element : ReaderNode) -> Bool {
element.first_or_empty("w:pPr").first_or_empty("w:rPr").first("w:del")
is Some(_)
}
///|
fn BodyReader::read_paragraph_elements(
self : BodyReader,
element : ReaderNode,
) -> ReaderItem raise DocxError {
self.read_paragraph_elements_with_prefix(element, [])
}
///|
fn BodyReader::read_paragraph_elements_with_prefix(
self : BodyReader,
element : ReaderNode,
deleted_prefix : Array[ReaderNode],
) -> ReaderItem raise DocxError {
let properties = self.read_paragraph_properties(
element.first_or_empty("w:pPr"),
)
// Every physical source paragraph records its own ordered carriers: the
// structure the projection's per-paragraph field classification
// observes. The children stream still reads the concatenation.
let inputs : Array[ReaderParagraphInput] = []
let carrier_stream : Array[ReaderNode] = []
for deleted in deleted_prefix {
let carriers = deleted.element_children()
inputs.push({ paragraph: deleted, carriers, })
carrier_stream.append(carriers)
}
let carriers = element.element_children()
inputs.push({ paragraph: element, carriers, })
carrier_stream.append(carriers)
// The extras are structural: owned by this paragraph, erased as its
// siblings. That split is the extras mechanism itself, and the erase
// walker -- not any caller -- is what flattens it back.
New({
source: element,
local_effects: [],
shape: ReaderParagraph(
inputs~,
children=self.read_paragraph_children(carrier_stream),
extras=self.read_paragraph_extra_elements(element),
properties~,
),
})
}
///|
priv enum ComplexField {
ComplexFieldBegin(checkbox_checked~ : Bool?, source~ : ReaderNode)
ComplexHyperlink(href~ : String?, anchor~ : String?, source~ : ReaderNode)
ComplexCheckbox(Bool, source~ : ReaderNode)
ComplexFieldUnknown
}
///|
fn BodyReader::read_paragraph_children(
self : BodyReader,
children : Array[ReaderNode],
) -> Array[ReaderItem] raise DocxError {
let items : Array[ReaderItem] = []
let stack : Array[ComplexField] = []
let mut instruction_text = ""
for element in children {
instruction_text += collect_instruction_text(element)
match read_field_char_type(element) {
Some("begin") => {
stack.push(
ComplexFieldBegin(
checkbox_checked=read_complex_field_checkbox_checked(element),
source=element,
),
)
instruction_text = ""
items.append(self.read_element(element))
}
Some("separate") => {
match stack.pop() {
Some(ComplexFieldBegin(checkbox_checked~, source~)) =>
stack.push(
parse_complex_field_instruction(
instruction_text, checkbox_checked, source,
),
)
Some(field) => stack.push(field)
None => ()
}
instruction_text = ""
items.append(self.read_element(element))
}
Some("end") => {
match stack.pop() {
Some(ComplexFieldBegin(checkbox_checked~, source~)) =>
match
parse_complex_field_instruction(
instruction_text, checkbox_checked, source,
) {
ComplexCheckbox(checked, source~) =>
items.push(reader_field_checkbox_run(checked, source))
_ => ()
}
Some(ComplexCheckbox(checked, source~)) =>
items.push(reader_field_checkbox_run(checked, source))
Some(_) => ()
None => ()
}
instruction_text = ""
items.append(self.read_element(element))
}
_ =>
if element.name() == "w:instrText" {
// consumed as instruction text above; nothing projects here
items.push(Trace(Suppressed(source=element)))
} else {
items.append(
wrap_items_with_current_hyperlink(stack, self.read_element(element)),
)
}
}
}
items
}
///|
/// The run a FORMCHECKBOX field synthesises at its end marker. Its source is
/// the field-begin node: the synthetic output has no child of its own to
/// blame, and the begin is where the field's identity lives.
fn reader_field_checkbox_run(checked : Bool, source : ReaderNode) -> ReaderItem {
New({
source,
local_effects: [],
shape: ReaderRun(
children=[
New({
source,
local_effects: [ProjectsVisibleNonText(source~)],
shape: ReaderCheckbox(checked),
}),
],
properties=RunProperties::default(),
),
})
}
///|
/// Wrap freshly read items in the innermost open HYPERLINK field, at the
/// item level, mirroring how the erased pipeline wrapped each ERASED
/// element: a run keeps the hyperlink INSIDE it, a paragraph's extras are
/// wrapped as independent siblings (they erase as siblings), traces pass
/// through untouched, and anything else is wrapped outside. The synthesised
/// hyperlink's source is the field-begin node.
fn wrap_items_with_current_hyperlink(
stack : Array[ComplexField],
items : Array[ReaderItem],
) -> Array[ReaderItem] {
match current_hyperlink_field(stack) {
Some((href, anchor, source)) => {
let wrapped : Array[ReaderItem] = []
for item in items {
wrap_item_with_hyperlink_into(item, href, anchor, source, wrapped)
}
wrapped
}
None => items
}
}
///|
fn wrap_item_with_hyperlink_into(
item : ReaderItem,
href : String?,
anchor : String?,
source : ReaderNode,
out : Array[ReaderItem],
) -> Unit {
fn hyperlink_of(children : Array[ReaderItem]) -> ReaderItem {
New({
source,
local_effects: [TransparentBoundary(source~)],
shape: ReaderHyperlink(children~, href~, anchor~, target_frame=None),
})
}
match item {
// effect traces are not output; wrapping one would fabricate an empty
// hyperlink element the reader never produced
Trace(_) => out.push(item)
New(
{
source: run_source,
local_effects,
shape: ReaderRun(children~, properties~),
}
) =>
out.push(
New({
source: run_source,
local_effects,
shape: ReaderRun(children=[hyperlink_of(children)], properties~),
}),
)
// a paragraph erases to itself plus its extras as siblings; the erased
// pipeline wrapped each of those separately
New(
{
source: paragraph_source,
local_effects,
shape: ReaderParagraph(inputs~, children~, extras~, properties~),
}
) => {
out.push(
hyperlink_of([
New({
source: paragraph_source,
local_effects,
shape: ReaderParagraph(inputs~, children~, extras=[], properties~),
}),
]),
)
for extra in extras {
wrap_item_with_hyperlink_into(extra, href, anchor, source, out)
}
}
other => out.push(hyperlink_of([other]))
}
}
///|
fn collect_instruction_text(element : ReaderNode) -> String raise DocxError {
let builder = StringBuilder()
if element.name() == "w:instrText" {
builder.write_string(element.text())
}
for child_element in element.element_children() {
builder.write_string(collect_instruction_text(child_element))
}
builder.to_string()
}
///|
fn read_field_char_type(element : ReaderNode) -> String? {
if element.name() == "w:fldChar" {
return element.attribute("w:fldCharType")
}
for child_element in element.element_children() {
match read_field_char_type(child_element) {
Some(value) => return Some(value)
None => ()
}
}
None
}
///|
fn read_complex_field_checkbox_checked(element : ReaderNode) -> Bool? {
match first_descendant_or_self(element, "w:fldChar") {
Some(field_char) =>
match field_char.first_or_empty("w:ffData").first("w:checkBox") {
Some(checkbox_element) =>
match checkbox_element.first("w:checked") {
Some(checked_element) =>
Some(read_boolean_element(Some(checked_element)))
None =>
Some(read_boolean_element(checkbox_element.first("w:default")))
}
None => None
}
None => None
}
}
///|
fn first_descendant_or_self(element : ReaderNode, name : String) -> ReaderNode? {
if element.name() == name {
return Some(element)
}
for child_element in element.element_children() {
match first_descendant_or_self(child_element, name) {
Some(result) => return Some(result)
None => ()
}
}
None
}
///|
fn parse_complex_field_instruction(
instruction_text : String,
checkbox_checked : Bool?,
source : ReaderNode,
) -> ComplexField {
match parse_hyperlink_field_instruction(instruction_text, source) {
Some(field) => field
None =>
parse_non_hyperlink_field_instruction(
instruction_text, checkbox_checked, source,
)
}
}
///|
fn parse_non_hyperlink_field_instruction(
instruction_text : String,
checkbox_checked : Bool?,
source : ReaderNode,
) -> ComplexField {
if instruction_text.contains("FORMCHECKBOX") {
ComplexCheckbox(checkbox_checked.unwrap_or(false), source~)
} else {
ComplexFieldUnknown
}
}
///|
fn parse_hyperlink_field_instruction(
instruction_text : String,
source : ReaderNode,
) -> ComplexField? {
let keyword = "HYPERLINK"
let mut index = skip_field_whitespace(instruction_text, 0)
if !field_starts_with_at(instruction_text, index, keyword) {
None
} else {
index = index + keyword.length()
if index >= instruction_text.length() ||
!is_field_whitespace(instruction_text[index]) {
return None
}
index = skip_field_whitespace(instruction_text, index)
let mut is_internal_link = false
if field_starts_with_at(instruction_text, index, "\\l") {
let after_switch = index + "\\l".length()
if after_switch < instruction_text.length() &&
is_field_whitespace(instruction_text[after_switch]) {
is_internal_link = true
index = skip_field_whitespace(instruction_text, after_switch)
}
}
match read_field_location(instruction_text, index) {
Some(location) =>
if is_internal_link {
Some(ComplexHyperlink(href=None, anchor=Some(location), source~))
} else {
Some(ComplexHyperlink(href=Some(location), anchor=None, source~))
}
None => None
}
}
}
///|
fn read_field_location(value : String, index : Int) -> String? {
if index >= value.length() {
None
} else if value[index] == '"' {
let tail = value[index + 1:]
match tail.rev_find("\"") {
Some(last_quote) =>
Some(value[index + 1:index + 1 + last_quote].to_owned())
None => read_unquoted_field_location(value, index)
}
} else {
read_unquoted_field_location(value, index)
}
}
///|
fn read_unquoted_field_location(value : String, index : Int) -> String? {
if index >= value.length() || value[index] == '\\' {
None
} else {
Some(read_until_field_whitespace(value, index))
}
}
///|
fn read_until_field_whitespace(value : String, start : Int) -> String {
for index in start.. Int {
let mut index = start
while index < value.length() && is_field_whitespace(value[index]) {
index = index + 1
}
index
}
///|
fn is_field_whitespace(char : UInt16) -> Bool {
char is (' ' | '\t' | '\n' | '\r')
}
///|
fn field_starts_with_at(value : String, index : Int, prefix : String) -> Bool {
index + prefix.length() <= value.length() &&
value[index:index + prefix.length()].to_owned() == prefix
}
///|
fn current_hyperlink_field(
stack : Array[ComplexField],
) -> (String?, String?, ReaderNode)? {
for index = stack.length() - 1; index >= 0; index = index - 1 {
match stack[index] {
ComplexHyperlink(href~, anchor~, source~) =>
return Some((href, anchor, source))
_ => ()
}
}
None
}
///|
fn BodyReader::read_text_box_elements(
self : BodyReader,
element : ReaderNode,
) -> Array[ReaderItem] raise DocxError {
match element.name() {
"mc:AlternateContent" =>
self.read_alternate_content_items(element, child => {
self.read_text_box_elements(child)
})
"w:txbxContent" => self.read_children(element.element_children())
_ => {
let items : Array[ReaderItem] = []
for child_element in element.element_children() {
items.append(self.read_text_box_elements(child_element))
}
items
}
}
}
///|
fn BodyReader::read_paragraph_extra_elements(
self : BodyReader,
element : ReaderNode,
) -> Array[ReaderItem] raise DocxError {
let items : Array[ReaderItem] = []
for child_element in element.element_children() {
match child_element.name() {
"w:pict" => items.append(self.read_pict_extra_elements(child_element))
"mc:AlternateContent" =>
items.append(self.read_text_box_elements(child_element))
"w:txbxContent" =>
items.append(self.read_text_box_elements(child_element))
_ => items.append(self.read_paragraph_extra_elements(child_element))
}
}
items
}
///|
fn BodyReader::read_pict_extra_elements(
self : BodyReader,
element : ReaderNode,
) -> Array[ReaderItem] raise DocxError {
let items : Array[ReaderItem] = []
for child_element in element.element_children() {
match child_element.name() {
"mc:AlternateContent" =>
items.append(
self.read_alternate_content_items(child_element, mc_child => {
self.read_pict_extra_elements(mc_child)
}),
)
"w:txbxContent" =>
items.append(self.read_children(child_element.element_children()))
"w:pict"
| "v:roundrect"
| "v:shape"
| "v:textbox"
| "v:group"
| "v:rect" => items.append(self.read_pict_extra_elements(child_element))
_ => items.append(self.read_element(child_element))
}
}
items
}
///|
fn BodyReader::read_structured_document_tag(
self : BodyReader,
element : ReaderNode,
) -> Array[ReaderItem] raise DocxError {
// selection effects in document order, name-blind like alternate
// content: only the FIRST `w:sdtContent` contributes, the properties are
// consulted metadata, and every other child is rejected where it sits
let prefix : Array[ReaderItem] = [Trace(TransparentBoundary(source=element))]
let suffix : Array[ReaderItem] = []
let mut content_node : ReaderNode? = None
let content : Array[ReaderItem] = []
for child in element.element_children() {
match child.name() {
"w:sdtContent" =>
if content_node is None {
content_node = Some(child)
content.append(self.read_children(child.element_children()))
} else {
suffix.push(Trace(Suppressed(source=child)))
}
"w:sdtPr" => {
let target = if content_node is None { prefix } else { suffix }
target.push(Trace(TransparentBoundary(source=child)))
}
_ => {
let target = if content_node is None { prefix } else { suffix }
target.push(Trace(Suppressed(source=child)))
}
}
}
let items = prefix
match read_structured_checkbox(element.first_or_empty("w:sdtPr")) {
Some(checked) => {
let (replaced, found) = replace_first_text_with_checkbox(content, checked)
if found {
items.append(replaced)
} else {
// no text to stand in for: the content is dropped wholesale and the
// checkbox is synthesized at the control itself
match content_node {
Some(node) => items.push(Trace(Suppressed(source=node)))
None => ()
}
items.push(
New({
source: element,
local_effects: [ProjectsVisibleNonText(source=element)],
shape: ReaderCheckbox(checked),
}),
)
}
}
None => items.append(content)
}
items.append(suffix)
items
}
///|
fn read_structured_checkbox(element : ReaderNode) -> Bool? {
match element.first("w14:checkbox") {
Some(checkbox_element) =>
match checkbox_element.first("w14:checked") {
Some(checked_element) =>
Some(
read_boolean_attribute_value(checked_element.attribute("w14:val")),
)
None => Some(false)
}
None => None
}
}
///|
fn read_boolean_attribute_value(value : String?) -> Bool {
value != Some("false") && value != Some("0")
}
///|
fn replace_first_text_with_checkbox(
items : Array[ReaderItem],
checked : Bool,
) -> (Array[ReaderItem], Bool) {
let replaced : Array[ReaderItem] = []
let mut found = false
for item in items {
if found {
replaced.push(item)
} else {
let (next, next_found) = replace_first_text_in_item(item, checked)
replaced.push(next)
if next_found {
found = true
}
}
}
(replaced, found)
}
///|
fn replace_first_text_in_item(
item : ReaderItem,
checked : Bool,
) -> (ReaderItem, Bool) {
match item {
New({ source, local_effects: _, shape: ReaderText(value), }) =>
if value.length() > 0 {
// the text's projection is suppressed and the checkbox stands in
// its place
(
New({
source,
local_effects: [
Suppressed(source~),
ProjectsVisibleNonText(source~),
],
shape: ReaderCheckbox(checked),
}),
true,
)
} else {
(item, false)
}
New(tree) => {
let (shape, found) = replace_first_text_in_shape(tree.shape, checked)
(
New({ source: tree.source, local_effects: tree.local_effects, shape, }),
found,
)
}
Trace(_) => (item, false)
}
}
///|
fn replace_first_text_in_shape(
shape : ReaderTreeShape,
checked : Bool,
) -> (ReaderTreeShape, Bool) {
// `ReaderText` never reaches this walker: `replace_first_text_in_item`
// intercepts it so the replacement can rewrite the tree's effects too
match shape {
ReaderParagraph(inputs~, children~, extras~, properties~) => {
// the erased pipeline expanded extras as siblings after the
// paragraph, so the search visits the carrier first, then extras
let (new_children, found) = replace_first_text_with_checkbox(
children, checked,
)
if found {
(
ReaderParagraph(inputs~, children=new_children, extras~, properties~),
true,
)
} else {
let (new_extras, found) = replace_first_text_with_checkbox(
extras, checked,
)
(
ReaderParagraph(
inputs~,
children=new_children,
extras=new_extras,
properties~,
),
found,
)
}
}
ReaderRun(children~, properties~) => {
let (new_children, found) = replace_first_text_with_checkbox(
children, checked,
)
(ReaderRun(children=new_children, properties~), found)
}
ReaderHyperlink(children~, href~, anchor~, target_frame~) => {
let (new_children, found) = replace_first_text_with_checkbox(
children, checked,
)
(
ReaderHyperlink(children=new_children, href~, anchor~, target_frame~),
found,
)
}
ReaderTable(children~, properties~) => {
let (new_children, found) = replace_first_text_with_checkbox(
children, checked,
)
(ReaderTable(children=new_children, properties~), found)
}
ReaderTableRow(children~, is_header~) => {
let (new_children, found) = replace_first_text_with_checkbox(
children, checked,
)
(ReaderTableRow(children=new_children, is_header~), found)
}
ReaderTableCell(children~, col_span~, row_span~) => {
let (new_children, found) = replace_first_text_with_checkbox(
children, checked,
)
(ReaderTableCell(children=new_children, col_span~, row_span~), found)
}
_ => (shape, false)
}
}
///|
fn BodyReader::read_paragraph_properties(
self : BodyReader,
element : ReaderNode,
) -> ParagraphProperties {
let style_id = non_empty_attribute(
element.first_or_empty("w:pStyle"),
"w:val",
)
let style_name = self.style_name(style_id, "paragraph")
{
style_id,
style_name,
numbering: self.read_numbering(style_id, element.first_or_empty("w:numPr")),
alignment: non_empty_attribute(element.first_or_empty("w:jc"), "w:val"),
indent: self.read_indent(element.first_or_empty("w:ind")),
}
}
///|
fn BodyReader::read_indent(self : BodyReader, element : ReaderNode) -> Indent {
ignore(self)
{
start: first_some(
non_empty_attribute(element, "w:start"),
non_empty_attribute(element, "w:left"),
),
end: first_some(
non_empty_attribute(element, "w:end"),
non_empty_attribute(element, "w:right"),
),
first_line: non_empty_attribute(element, "w:firstLine"),
hanging: non_empty_attribute(element, "w:hanging"),
}
}
///|
fn BodyReader::read_numbering(
self : BodyReader,
style_id : String?,
element : ReaderNode,
) -> Numbering? {
let level = element.first_or_empty("w:ilvl").attribute("w:val")
let num_id = element.first_or_empty("w:numId").attribute("w:val")
match (level, num_id) {
(Some(level), Some(num_id)) => self.numbering.find_level(num_id, level)
_ =>
match style_id {
Some(style_id) =>
match self.numbering.find_level_by_paragraph_style_id(style_id) {
Some(numbering) => Some(numbering)
None =>
match num_id {
Some(num_id) => self.numbering.find_level(num_id, "0")
None => None
}
}
None =>
match num_id {
Some(num_id) => self.numbering.find_level(num_id, "0")
None => None
}
}
}
}
///|
fn BodyReader::read_run(
self : BodyReader,
element : ReaderNode,
) -> ReaderItem raise DocxError {
let properties = self.read_run_properties(element.first_or_empty("w:rPr"))
New({
source: element,
local_effects: [],
shape: ReaderRun(
children=self.read_children(element.element_children()),
properties~,
),
})
}
///|
fn BodyReader::read_run_properties(
self : BodyReader,
element : ReaderNode,
) -> RunProperties {
let style_id = non_empty_attribute(
element.first_or_empty("w:rStyle"),
"w:val",
)
let style_name = self.style_name(style_id, "character")
{
style_id,
style_name,
is_bold: read_boolean_element(element.first("w:b")),
is_underline: read_underline(element.first("w:u")),
is_italic: read_boolean_element(element.first("w:i")),
is_strikethrough: read_boolean_element(element.first("w:strike")),
is_all_caps: read_boolean_element(element.first("w:caps")),
is_small_caps: read_boolean_element(element.first("w:smallCaps")),
vertical_alignment: read_vertical_alignment(
element.first_or_empty("w:vertAlign").attribute("w:val"),
),
font: non_empty_attribute(element.first_or_empty("w:rFonts"), "w:ascii"),
font_size: read_font_size(element.first_or_empty("w:sz").attribute("w:val")),
highlight: read_highlight(
element.first_or_empty("w:highlight").attribute("w:val"),
),
}
}
///|
fn BodyReader::style_name(
self : BodyReader,
style_id : String?,
style_type : String,
) -> String? {
match style_id {
Some(id) =>
match self.styles.find_style(style_type, id) {
Some(style) => if style.name == "" { None } else { Some(style.name) }
None => {
self.warn_undefined_style(style_type, id)
None
}
}
None => None
}
}
///|
fn BodyReader::warn_undefined_style(
self : BodyReader,
style_type : String,
style_id : String,
) -> Unit {
self.warn_parts([
style_warning_label(style_type),
" style with ID ",
style_id,
" was referenced but not defined in the document",
])
}
///|
fn style_warning_label(style_type : String) -> String {
match style_type {
"paragraph" => "Paragraph"
"character" => "Run"
"table" => "Table"
_ => style_type
}
}
///|
fn read_boolean_element(element : ReaderNode?) -> Bool {
match element {
Some(element) => {
let value = element.attribute("w:val")
value != Some("false") && value != Some("0")
}
None => false
}
}
///|
fn read_underline(element : ReaderNode?) -> Bool {
match element {
Some(element) => {
let value = element.attribute("w:val")
value != None &&
value != Some("false") &&
value != Some("0") &&
value != Some("none")
}
None => false
}
}
///|
fn read_vertical_alignment(value : String?) -> VerticalAlignment {
match value {
Some("superscript") => Superscript
Some("subscript") => Subscript
_ => Baseline
}
}
///|
fn read_font_size(value : String?) -> Int? {
match value {
Some(text) =>
if is_ascii_decimal_string(text) {
try @string.parse_int(text) catch {
_ => None
} noraise {
value => Some(value / 2)
}
} else {
None
}
None => None
}
}
///|
fn is_ascii_decimal_string(value : String) -> Bool {
let mut is_decimal = value.length() > 0
for index in 0.. 57 {
is_decimal = false
}
}
is_decimal
}
///|
fn[T] first_some(first : T?, second : T?) -> T? {
match first {
Some(_) => first
None => second
}
}
///|
fn read_highlight(value : String?) -> String? {
match value {
Some("") | Some("none") | None => None
Some(value) => Some(value)
}
}
///|
fn BodyReader::read_break(
self : BodyReader,
element : ReaderNode,
) -> Array[ReaderItem] {
match element.attribute("w:type") {
None | Some("textWrapping") =>
[
New({
source: element,
local_effects: [ProjectsVisibleNonText(source=element)],
shape: ReaderBreak(Line),
}),
]
Some("page") =>
[
New({
source: element,
local_effects: [ProjectsVisibleNonText(source=element)],
shape: ReaderBreak(Page),
}),
]
Some("column") =>
[
New({
source: element,
local_effects: [ProjectsVisibleNonText(source=element)],
shape: ReaderBreak(Column),
}),
]
Some(break_type) => {
self.warn_parts(["Unsupported break type: ", break_type])
[Trace(Suppressed(source=element))]
}
}
}
///|
fn BodyReader::read_hyperlink(
self : BodyReader,
element : ReaderNode,
) -> Array[ReaderItem] raise DocxError {
let target_frame = non_empty_attribute(element, "w:tgtFrame")
let anchor = non_empty_attribute(element, "w:anchor")
match non_empty_attribute(element, "r:id") {
Some(id) => {
let href = match self.relationship_index.find_unique_target_by_id(id) {
Some(target) =>
match anchor {
Some(anchor) => Some(replace_url_fragment(target, anchor))
None => Some(target)
}
None => None
}
[
New({
source: element,
local_effects: [TransparentBoundary(source=element)],
shape: ReaderHyperlink(
children=self.read_children(element.element_children()),
href~,
anchor=None,
target_frame~,
),
}),
]
}
None =>
match anchor {
Some(anchor) =>
[
New({
source: element,
local_effects: [TransparentBoundary(source=element)],
shape: ReaderHyperlink(
children=self.read_children(element.element_children()),
href=None,
anchor=Some(anchor),
target_frame~,
),
}),
]
// untargeted and unanchored: the reader flattens, so the child
// items pass through behind a boundary
None =>
reader_transparent_items(
element,
self.read_children(element.element_children()),
)
}
}
}
///|
fn replace_url_fragment(href : String, fragment : String) -> String {
let base = match href.find("#") {
Some(index) => href[:index].to_owned()
None => href
}
base + "#" + fragment
}
///|
fn non_empty_attribute(element : ReaderNode, name : String) -> String? {
match element.attribute(name) {
Some(value) => if value == "" { None } else { Some(value) }
None => None
}
}
///|
fn BodyReader::read_drawing_element(
self : BodyReader,
element : ReaderNode,
) -> Array[ReaderItem] {
let results : Array[ReaderItem] = []
for blip in element.drawing_blips() {
match self.read_blip(element, blip) {
Some(image) =>
results.push(
self.wrap_image_with_drawing_hyperlink(element, blip, image),
)
// the blip resolved to no image; nothing materialises for it
None => results.push(Trace(Suppressed(source=blip)))
}
}
if results.is_empty() {
// no blips at all: the drawing container itself materialised nothing
results.push(Trace(Suppressed(source=element)))
}
results
}
///|
fn ReaderNode::drawing_blips(self : ReaderNode) -> Array[ReaderNode] {
let blips : Array[ReaderNode] = []
for graphic in self.elements_by_tag_name("a:graphic") {
for graphic_data in graphic.elements_by_tag_name("a:graphicData") {
for picture in graphic_data.elements_by_tag_name("pic:pic") {
for fill in picture.elements_by_tag_name("pic:blipFill") {
blips.append(fill.elements_by_tag_name("a:blip"))
}
}
}
}
blips
}
///|
fn BodyReader::wrap_image_with_drawing_hyperlink(
self : BodyReader,
drawing_element : ReaderNode,
blip : ReaderNode,
image : Image,
) -> ReaderItem {
let image_item = New({
source: blip,
local_effects: [ProjectsVisibleNonText(source=blip)],
shape: ReaderImage(image),
})
match self.read_drawing_hyperlink(drawing_element) {
Some(href) =>
New({
source: drawing_element,
local_effects: [TransparentBoundary(source=drawing_element)],
shape: ReaderHyperlink(
children=[image_item],
href=Some(href),
anchor=None,
target_frame=None,
),
})
None => image_item
}
}
///|
fn BodyReader::read_drawing_hyperlink(
self : BodyReader,
drawing_element : ReaderNode,
) -> String? {
for
link in drawing_element
.first_or_empty("wp:docPr")
.elements_by_tag_name("a:hlinkClick") {
match non_empty_attribute(link, "r:id") {
Some(id) =>
match self.relationship_index.find_unique_target_by_id(id) {
Some(target) => return Some(target)
None => ()
}
None => ()
}
}
None
}
///|
fn BodyReader::read_blip(
self : BodyReader,
drawing_element : ReaderNode,
blip : ReaderNode,
) -> Image? {
if !self.read_images {
return None
}
let diagnostic_count = self.diagnostic_count()
let image = match non_empty_attribute(blip, "r:embed") {
Some(id) =>
self.read_image_by_relationship(
id,
alt_text=read_drawing_alt_text(drawing_element),
)
None =>
match non_empty_attribute(blip, "r:link") {
Some(id) =>
self.read_linked_image_by_relationship(
id,
alt_text=read_drawing_alt_text(drawing_element),
)
None => None
}
}
match image {
Some(image) => Some(image)
None => {
if self.diagnostic_count() == diagnostic_count {
self.warn("Could not find image file for a:blip element")
}
None
}
}
}
///|
fn BodyReader::diagnostic_count(self : BodyReader) -> Int {
let collected = match self.diagnostics {
Some(value) => value.received()
None => 0
}
self.messages.length() + collected
}
///|
fn BodyReader::read_vml_image_data(
self : BodyReader,
element : ReaderNode,
) -> Array[ReaderItem] {
if !self.read_images {
return [Trace(Suppressed(source=element))]
}
match non_empty_attribute(element, "r:id") {
Some(id) =>
match
self.read_image_by_relationship(
id,
alt_text=element.attribute("o:title"),
) {
Some(image) =>
[
New({
source: element,
local_effects: [ProjectsVisibleNonText(source=element)],
shape: ReaderImage(image),
}),
]
// unresolved relationship: the image never materialises
None => [Trace(Suppressed(source=element))]
}
None => {
self.warn("A v:imagedata element without a relationship ID was ignored")
[Trace(Suppressed(source=element))]
}
}
}
///|
fn BodyReader::read_image_by_relationship(
self : BodyReader,
relationship_id : String,
alt_text~ : String?,
) -> Image? {
if !self.read_images {
return None
}
match self.relationship_index.find_unique_by_id(relationship_id) {
Some(rel) =>
match resolve_part_target(self.base_path, rel.target) {
Some(path) =>
match self.zip.read_bytes(path) {
Some(data) => Some(self.make_image(path, data, alt_text~))
None => None
}
None => None
}
None => None
}
}
///|
fn BodyReader::read_linked_image_by_relationship(
self : BodyReader,
relationship_id : String,
alt_text~ : String?,
) -> Image? {
if !self.read_images {
return None
}
match self.relationship_index.find_unique_by_id(relationship_id) {
Some(rel) => self.read_external_image(rel.target, alt_text~)
None => None
}
}
///|
fn BodyReader::read_external_image(
self : BodyReader,
target : String,
alt_text~ : String?,
) -> Image? {
if !self.external_files.enabled {
self.error_parts([
"could not read external image '", target, "', external file access is disabled",
])
return None
}
for path in external_file_candidate_paths(target) {
match (self.external_files.read_file)(path) {
Some(data) => return Some(self.make_image(path, data, alt_text~))
None => ()
}
}
self.error_parts([
"could not find external image '", target, "', path of input document is unknown",
])
None
}
///|
fn external_file_candidate_paths(target : String) -> Array[String] {
let paths = [target]
match external_uri_to_path(target) {
Some(path) => if path != target { paths.push(path) }
None => ()
}
paths
}
///|
fn external_uri_to_path(uri : String, platform? : String = "") -> String? {
let raw_path = if uri.has_prefix("file://") {
local_file_uri_path(uri)
} else if is_relative_uri(uri) {
Some(strip_uri_fragment(uri))
} else {
None
}
match raw_path {
Some(path) =>
match percent_decode_uri_path(path) {
Some(decoded) =>
if platform == "win32" && is_windows_file_uri_path(decoded) {
Some(decoded[1:].to_owned())
} else {
Some(decoded)
}
None => None
}
None => None
}
}
///|
fn local_file_uri_path(uri : String) -> String? {
let rest = strip_uri_fragment(uri)["file://".length():]
if rest.has_prefix("/") {
Some(rest.to_owned())
} else if rest.has_prefix("?") {
Some(rest.to_owned())
} else if rest.has_prefix("localhost?") {
Some("/" + rest["localhost".length():].to_owned())
} else {
match rest.find("/") {
Some(slash) => {
let host = rest[:slash].to_owned()
if host == "" || host == "localhost" {
Some(rest[slash:].to_owned())
} else {
None
}
}
None =>
if rest == "" {
Some("null")
} else if rest == "localhost" {
Some("/")
} else {
None
}
}
}
}
///|
fn is_relative_uri(uri : String) -> Bool {
find_uri_scheme_colon(uri) == None
}
///|
fn strip_uri_fragment(uri : String) -> String {
match uri.find("#") {
Some(index) => uri[:index].to_owned()
None => uri
}
}
///|
fn find_uri_scheme_colon(uri : String) -> Int? {
if uri.length() == 0 || !is_uri_scheme_start(uri[0]) {
return None
}
for index in 1.. return Some(index)
'/' | '\\' | '?' | '#' => return None
char => if !is_uri_scheme_char(char) { return None }
}
}
None
}
///|
fn is_uri_scheme_start(char : UInt16) -> Bool {
char is ('A'..='Z' | 'a'..='z')
}
///|
fn is_uri_scheme_char(char : UInt16) -> Bool {
is_uri_scheme_start(char) || char is ('0'..='9' | '+' | '-' | '.')
}
///|
fn is_windows_file_uri_path(path : String) -> Bool {
path.length() >= 3 &&
path[0] == '/' &&
is_uri_scheme_start(path[1]) &&
path[2] == ':'
}
///|
fn percent_decode_uri_path(path : String) -> String? {
let bytes : Array[Byte] = []
let mut index = 0
while index < path.length() {
if index + 2 < path.length() && path[index] == '%' {
match (hex_value(path[index + 1]), hex_value(path[index + 2])) {
(Some(high), Some(low)) => {
bytes.push((high * 16 + low).to_byte())
index += 3
continue
}
_ => return None
}
} else if path[index] == '%' {
return None
}
bytes.push(path[index].to_int().to_byte())
index += 1
}
try @utf8.decode(Bytes::from_array(bytes)) catch {
_ => None
} noraise {
value => Some(value)
}
}
///|
fn hex_value(char : UInt16) -> Int? {
match char {
'0'..='9' => Some(char.to_int() - '0'.to_int())
'A'..='F' => Some(char.to_int() - 'A'.to_int() + 10)
'a'..='f' => Some(char.to_int() - 'a'.to_int() + 10)
_ => None
}
}
///|
fn BodyReader::make_image(
self : BodyReader,
path : String,
data : BytesView,
alt_text~ : String?,
) -> Image {
let content_type = self.content_types.find_content_type(path, zip=self.zip)
if !is_supported_web_image_type(content_type) {
self.warn_parts([
"Image of type ", content_type, " is unlikely to display in web browsers",
])
}
{ content_type, alt_text, data: data.to_owned(), }
}
///|
fn is_supported_web_image_type(content_type : String) -> Bool {
content_type == "image/png" ||
content_type == "image/gif" ||
content_type == "image/jpeg" ||
content_type == "image/svg+xml" ||
content_type == "image/tiff"
}
///|
fn read_drawing_alt_text(element : ReaderNode) -> String? {
let properties = element.first_or_empty("wp:docPr")
match properties.attribute("descr") {
Some(value) =>
if value.trim().is_empty() {
properties.attribute("title")
} else {
Some(value)
}
None => properties.attribute("title")
}
}
///|
fn resolve_part_target(base_path : String, target : String) -> String? {
@opc.resolve_part_target(base_path, target)
}
///|
fn BodyReader::read_table(
self : BodyReader,
element : ReaderNode,
) -> ReaderItem raise DocxError {
New({
source: element,
local_effects: [],
shape: ReaderTable(
children=self.calculate_table_row_spans(
self.read_children(element.element_children()),
),
properties=self.read_table_properties(element.first_or_empty("w:tblPr")),
),
})
}
///|
fn BodyReader::read_table_properties(
self : BodyReader,
element : ReaderNode,
) -> TableProperties {
let style_id = non_empty_attribute(
element.first_or_empty("w:tblStyle"),
"w:val",
)
{ style_id, style_name: self.style_name(style_id, "table"), }
}
///|
fn BodyReader::read_table_row(
self : BodyReader,
element : ReaderNode,
) -> Array[ReaderItem] raise DocxError {
let properties = element.first_or_empty("w:trPr")
if properties.first("w:del") is Some(_) {
[Trace(Suppressed(source=element))]
} else {
let is_header = properties.first("w:tblHeader") is Some(_)
[
New({
source: element,
local_effects: [],
shape: ReaderTableRow(
children=self.read_children(element.element_children()),
is_header~,
),
}),
]
}
}
///|
fn BodyReader::read_table_cell(
self : BodyReader,
element : ReaderNode,
) -> ReaderItem raise DocxError {
let properties = element.first_or_empty("w:tcPr")
let col_span = parse_grid_span(
properties.first_or_empty("w:gridSpan").attribute_or("w:val", "1"),
)
New({
source: element,
local_effects: [],
shape: ReaderTableCell(
children=self.read_children(element.element_children()),
col_span~,
row_span=if read_vertical_merge_continue(properties) { 0 } else { 1 },
),
})
}
///|
fn parse_grid_span(value : String) -> Int {
let mut index = 0
while index < value.length() && is_field_whitespace(value[index]) {
index += 1
}
let mut sign = 1
if index < value.length() && (value[index] == '+' || value[index] == '-') {
if value[index] == '-' {
sign = -1
}
index += 1
}
let start = index
let mut result = 0
while index < value.length() {
match decimal_digit_value(value[index]) {
Some(digit) => {
result = result * 10 + digit
index += 1
}
None => break
}
}
if index == start {
1
} else {
sign * result
}
}
///|
fn decimal_digit_value(char : UInt16) -> Int? {
match char {
'0'..='9' => Some(char.to_int() - '0'.to_int())
_ => None
}
}
///|
fn read_vertical_merge_continue(properties : ReaderNode) -> Bool {
match properties.first("w:vMerge") {
Some(element) =>
match element.attribute("w:val") {
None | Some("continue") => true
_ => false
}
None => false
}
}
///|
fn BodyReader::table_cells_for_span(
self : BodyReader,
children : Array[ReaderItem],
) -> (Array[TableCellForSpan], Array[ReaderItem])? {
let cells : Array[TableCellForSpan] = []
let pending : Array[ReaderItem] = []
for child in children {
match child {
// effect traces carry no structure; they ride along in place
Trace(_) => pending.push(child)
New(
{
source: cell_source,
local_effects: _,
shape: ReaderTableCell(children=cell_children, col_span~, row_span~),
}
) => {
cells.push({
children: cell_children,
col_span,
row_span,
source: cell_source,
leading: pending.copy(),
})
pending.clear()
}
_ => {
self.warn(
"unexpected non-cell element in table row, cell merging may be incorrect",
)
return None
}
}
}
Some((cells, pending))
}
///|
fn BodyReader::calculate_table_row_spans(
self : BodyReader,
items : Array[ReaderItem],
) -> Array[ReaderItem] {
// two passes, matching the erased pipeline's warning order: every table
// child is checked to be a row before any row's cells are examined
let row_shells : Array[
(Array[ReaderItem], Bool, ReaderNode, Array[ReaderItem]),
] = []
let pending : Array[ReaderItem] = []
for item in items {
match item {
// effect traces carry no structure; they ride along in place
Trace(_) => pending.push(item)
New(
{
source,
local_effects: _,
shape: ReaderTableRow(children~, is_header~),
}
) => {
row_shells.push((children, is_header, source, pending.copy()))
pending.clear()
}
_ => {
self.warn(
"unexpected non-row element in table, cell merging may be incorrect",
)
return items
}
}
}
let typed_rows : Array[TableRowForSpan] = []
for shell in row_shells {
let (children, is_header, source, leading) = shell
guard self.table_cells_for_span(children) is Some((cells, trailing)) else {
return items
}
typed_rows.push({ cells, trailing, is_header, source, leading, })
}
let rows = self.calculate_typed_table_row_spans(typed_rows)
rows.append(pending)
rows
}
///|
fn BodyReader::calculate_typed_table_row_spans(
_self : BodyReader,
elements : Array[TableRowForSpan],
) -> Array[ReaderItem] {
let row_spans : Array[Array[Int]] = []
let skip_cells : Array[Array[Bool]] = []
for row in elements {
let spans : Array[Int] = []
let skips : Array[Bool] = []
for _ in row.cells {
spans.push(1)
skips.push(false)
}
row_spans.push(spans)
skip_cells.push(skips)
}
let open_cells : Map[Int, (Int, Int)] = Map([])
for row_index in 0.. {
row_spans[origin_row][origin_cell] = row_spans[origin_row][origin_cell] +
1
skip_cells[row_index][cell_index] = true
}
None => open_cells[column_index] = (row_index, cell_index)
}
} else {
open_cells[column_index] = (row_index, cell_index)
}
column_index = column_index + cell.col_span
}
}
let rows : Array[ReaderItem] = []
for row_index in 0..