// The joined production bundle (#434 PR 6a): ONE authoritative joined
// read of a story part, segmented the way production consumes it. The
// document body is one segment; a header or footer root's children are
// one segment; each accepted note or comment container is its own, so
// pending content can never leak across containers while coordinates
// stay continuous across the whole part. The same segment items feed
// both the erased AST and the mutation projection -- the reader must
// not run twice over one story.
///|
priv struct ReaderOutputSegment {
container : ReaderNode
items : Array[ReaderItem]
}
///|
priv struct JoinedReaderOutput {
story : JoinedReaderStory
segments : Array[ReaderOutputSegment]
}
///|
/// Why a joined story could not be segmented for mutation-safe
/// consumption. Production READING narrows silently -- an unexpected
/// container simply never surfaces -- but a mutation-safe view must not
/// silently lose story content, so every structural surprise refuses.
priv enum ReaderSegmentationRefusal {
UnexpectedStoryRoot(name~ : String)
MissingDocumentBody
UnexpectedContainer(root~ : String, name~ : String)
}
///|
priv enum ReaderSegmentationResult {
SegmentedStory(JoinedReaderOutput)
SegmentationRefused(ReaderSegmentationRefusal)
}
///|
/// A bounded human-readable form for tests and diagnostics.
fn ReaderSegmentationRefusal::describe(
self : ReaderSegmentationRefusal,
) -> String {
match self {
UnexpectedStoryRoot(name~) => "unexpected story root " + name
MissingDocumentBody => "the document story has no body"
UnexpectedContainer(root~, name~) =>
"unexpected direct child " + name + " of " + root
}
}
///|
/// Refusal diagnostics never carry unbounded input: an expanded XML name
/// is clipped to at most 64 UTF-16 units before it is stored in a
/// refusal, so a hostile namespace URI cannot ride a describe() into
/// logs or messages.
const READER_SEGMENTATION_NAME_LIMIT = 64
///|
fn reader_segmentation_clip_name(name : String) -> String {
if name.length() <= READER_SEGMENTATION_NAME_LIMIT {
return name
}
let output = StringBuilder()
let mut units = 0
for character in name {
// the cap is UTF-16 units, the length() currency: a non-BMP
// character spends two
let width = if character.to_int() > 0xFFFF { 2 } else { 1 }
if units + width > READER_SEGMENTATION_NAME_LIMIT - 1 {
break
}
output.write_char(character)
units += width
}
output.write_string("\u{2026}")
output.to_string()
}
///|
/// Word's plumbing notes -- separator rules and continuation notices --
/// which production note reading skips rather than surfacing as content.
/// Comments carry no such convention: production reads every comment
/// whatever its attributes say.
fn reader_note_container_is_plumbing(container : ReaderNode) -> Bool {
match container.attribute("w:type") {
Some("separator")
| Some("continuationSeparator")
| Some("continuationNotice") => true
_ => false
}
}
///|
/// Segments one joined story and performs the one authoritative read of
/// each segment's children, mirroring production consumption exactly:
/// the document's body (a `w:background` sibling is presentation, not
/// story content, and is not read), a header or footer root's own
/// children, and each name-matching note or comment container. Container
/// elements themselves are never read. Where production reading would
/// silently drop content -- a foreign direct child of the document or of
/// a notes or comments root, a second body, a bodyless document, an
/// unknown root -- segmentation refuses instead: a mutation-safe view
/// must not quietly narrow the story.
fn segment_joined_reader_story(
story : JoinedReaderStory,
reader : BodyReader,
) -> ReaderSegmentationResult raise DocxError {
let root = story.root
let segments : Array[ReaderOutputSegment] = []
fn container_segments(
container_name : String,
) -> ReaderSegmentationRefusal? raise DocxError {
let filter_plumbing = container_name != "w:comment"
for container in root.element_children() {
if container.name() != container_name {
return Some(
UnexpectedContainer(
root=root.name(),
name=reader_segmentation_clip_name(container.name()),
),
)
}
if filter_plumbing && reader_note_container_is_plumbing(container) {
continue
}
segments.push({
container,
items: reader.read_children(container.element_children()),
})
}
None
}
match root.name() {
"w:document" => {
// the document child model is w:background then w:body; anything
// else -- a foreign child, a second body -- refuses rather than
// silently vanishing from the mutation-safe view
let mut body : ReaderNode? = None
let mut background_seen = false
for child in root.element_children() {
match child.name() {
"w:background" =>
// the schema sequence is AT MOST ONE background, BEFORE the
// body; a duplicate or trailing background is invalid
// structure that must not silently vanish
if background_seen || body is Some(_) {
return SegmentationRefused(
UnexpectedContainer(root="w:document", name="w:background"),
)
} else {
background_seen = true
}
"w:body" =>
match body {
None => body = Some(child)
Some(_) =>
return SegmentationRefused(
UnexpectedContainer(root="w:document", name="w:body"),
)
}
name =>
return SegmentationRefused(
UnexpectedContainer(
root="w:document",
name=reader_segmentation_clip_name(name),
),
)
}
}
match body {
Some(body) =>
segments.push({
container: body,
items: reader.read_children(body.element_children()),
})
None => return SegmentationRefused(MissingDocumentBody)
}
}
"w:hdr" | "w:ftr" =>
segments.push({
container: root,
items: reader.read_children(root.element_children()),
})
"w:footnotes" =>
match container_segments("w:footnote") {
Some(refusal) => return SegmentationRefused(refusal)
None => ()
}
"w:endnotes" =>
match container_segments("w:endnote") {
Some(refusal) => return SegmentationRefused(refusal)
None => ()
}
"w:comments" =>
match container_segments("w:comment") {
Some(refusal) => return SegmentationRefused(refusal)
None => ()
}
name =>
return SegmentationRefused(
UnexpectedStoryRoot(name=reader_segmentation_clip_name(name)),
)
}
SegmentedStory({ story, segments, })
}
///|
/// Projects a joined bundle: every segment walks through ONE shared
/// builder with a segment boundary between, so paragraph coordinates and
/// carrier events stay part-wide continuous while ownership stays
/// segment-local.
fn project_joined_reader_output(
output : JoinedReaderOutput,
budget? : ReaderProjectionBudget,
) -> ReaderProjectionBuildResult raise DocxError {
let segments : Array[Array[ReaderItem]] = []
for segment in output.segments {
segments.push(segment.items)
}
project_reader_item_segments(output.story.scan, segments, budget?)
}
///|
/// Which reader semantics a `DocumentParts` uses for story parts.
/// Ordinary and tolerant-annotated reading keep the tolerant unjoined
/// path; mutation-safe and joined read-only annotated reads go joined -- and
/// once joined there is no fallback: a join, segmentation or projection
/// failure refuses the whole read.
priv enum StoryReaderMode {
TolerantReader
JoinedMutationReader
}
///|
/// One joined story read: the bundle whose segments carry the ONE
/// authoritative read, and its classified projection.
priv struct JoinedStoryRead {
output : JoinedReaderOutput
projection : ReaderProjection
}
///|
/// Maps a join refusal out of the no-fallback pipeline: stage causes
/// keep their type (a resource ceiling stays a resource ceiling, bad
/// XML stays bad XML), while the join's own findings become typed
/// Unsupported with bounded diagnostics.
fn reader_join_refusal_error(
part : String,
refusal : ReaderInputJoinRefusal,
) -> DocxError {
match refusal {
StageRefused(stage=_, cause~) =>
match cause {
InvalidXml(message~) =>
InvalidXml(
message="mutation-safe story read of '\{part}': \{message}",
)
Unsupported(message~) =>
Unsupported(
message="mutation-safe story read of '\{part}': \{message}",
)
other => other
}
other =>
Unsupported(
message="mutation-safe reader join refused for '\{part}': \{other.describe()}",
)
}
}
///|
/// The one authoritative mutation-safe read of a story part from its
/// bytes: strict gate, scan, tolerant parse, join, segmentation,
/// projection, field classification. Every failure is a typed refusal.
fn read_joined_story_bytes(
part : String,
bytes : BytesView,
reader : BodyReader,
strict_budget? : @xml.XmlReadBudget,
tolerant_budget? : @xml.XmlReadBudget,
path_budget? : AnnotationPathBudget,
) -> JoinedStoryRead raise DocxError {
match
join_reader_input_story_limited(
bytes,
strict_budget?,
tolerant_budget?,
path_budget?,
) {
Refused(refusal) => raise reader_join_refusal_error(part, refusal)
Joined(story) => finish_joined_story_read(part, story, reader)
}
}
///|
/// The main-document form: the tolerant DOM is already parsed and the
/// strict gate already ran beside it, so only the scan and the join are
/// added -- nothing parses twice and budget accounting is unchanged.
fn read_joined_story_prepared(
part : String,
bytes : BytesView,
root : XmlElement,
reader : BodyReader,
path_budget? : AnnotationPathBudget,
) -> JoinedStoryRead raise DocxError {
let scan = scan_projection_source_tree(bytes, path_budget?) catch {
cause =>
raise reader_join_refusal_error(
part,
StageRefused(stage=SourceScan, cause~),
)
}
match join_reader_dom(root, scan) {
Refused(refusal) => raise reader_join_refusal_error(part, refusal)
Joined(story) => finish_joined_story_read(part, story, reader)
}
}
///|
fn finish_joined_story_read(
part : String,
story : JoinedReaderStory,
reader : BodyReader,
) -> JoinedStoryRead raise DocxError {
match segment_joined_reader_story(story, reader) {
SegmentationRefused(refusal) =>
raise Unsupported(
message="mutation-safe segmentation refused for '\{part}': \{refusal.describe()}",
)
SegmentedStory(output) =>
match project_joined_reader_output(output) {
ProjectionRefused(refusal) =>
raise Unsupported(
message="mutation-safe reader projection refused for '\{part}': \{refusal.describe()}",
)
Projected(projection) => {
classify_reader_projection_fields(projection)
{ output, projection, }
}
}
}
}