// L0 span queries: the byte-level insertion offsets the preservation
// (splice) layer needs, resolved from the J1 scanner's projection of
// the ORIGINAL part bytes. Offsets index into the story part named by
// `main_story_part` — never into the whole zip.
///|
/// The byte extent of one BODY-story projection node (paragraph or
/// run) in the original word-processing part, plus the two insertion
/// offsets annotation surgery uses: `content_start` (just past the
/// open tag AND past a leading pPr/rPr — the property container leads
/// its sequence, so content and markers must land after it) and
/// `close_tag_start` (the '<' of the close tag). Both are None for a
/// self-closing form, which has no interior — the splice layer must
/// first rewrite it to the open form (a byte edit of this node's own
/// span).
pub struct NodeSpan {
priv byte_start : Int
priv byte_end : Int
priv content_start : Int
priv close_tag_start : Int
}
///|
/// Offset of the '<' of the paragraph's open tag.
pub fn NodeSpan::byte_start(self : NodeSpan) -> Int {
self.byte_start
}
///|
/// Offset just past the '>' of the paragraph's close tag (exclusive).
pub fn NodeSpan::byte_end(self : NodeSpan) -> Int {
self.byte_end
}
///|
/// Earliest interior insertion offset (past the open tag and any
/// leading pPr), or None for a self-closing paragraph.
pub fn NodeSpan::content_start(self : NodeSpan) -> Int? {
if self.content_start < 0 {
None
} else {
Some(self.content_start)
}
}
///|
/// Offset of the '<' of the close tag, or None for a self-closing
/// paragraph.
pub fn NodeSpan::close_tag_start(self : NodeSpan) -> Int? {
if self.close_tag_start < 0 {
None
} else {
Some(self.close_tag_start)
}
}
///|
/// True when the paragraph is the self-closing `` form.
pub fn NodeSpan::self_closing(self : NodeSpan) -> Bool {
self.content_start < 0
}
///|
/// The zip entry name of the MAIN document part the body-story spans
/// index into (resolved through the officeDocument relationship, never
/// hardcoded).
pub fn DocxAnnotatedResult::main_story_part(
self : DocxAnnotatedResult,
) -> String {
self.main_part
}
///|
/// The span of the body-story paragraph at `relative_path` — the
/// story-relative ORDINAL path exactly as the index emits it
/// ("p[3]", "tbl[1]/tr[2]/tc[1]/p[1]") — or None when no such
/// paragraph was projected. Offsets index into `main_story_part`'s
/// original bytes.
pub fn DocxAnnotatedResult::body_paragraph_span(
self : DocxAnnotatedResult,
relative_path : String,
) -> NodeSpan? {
body_node_span_of_kind(self, relative_path, "p")
}
///|
/// The span of the body-story RUN at `relative_path` ("p[3]/r[2]", the
/// index's ordinal form), or None. Runs carry the same offsets as
/// paragraphs (content_start past a leading rPr) so L1 can rewrite a
/// self-closing `` by its own extent too.
pub fn DocxAnnotatedResult::body_run_span(
self : DocxAnnotatedResult,
relative_path : String,
) -> NodeSpan? {
body_node_span_of_kind(self, relative_path, "r")
}
///|
/// The ROOT element's span for a scanned story ("/body", "/comments",
/// "/footnotes", "/endnotes"), or None when the story was not scanned.
/// L1 splices a new comment definition just before the comments
/// story's `close_tag_start`; a self-closing root reports no interior
/// and must be rewritten by its own extent first.
pub fn DocxAnnotatedResult::story_root_span(
self : DocxAnnotatedResult,
story : String,
) -> NodeSpan? {
for entry in self.index.scans {
let (key, scan) = entry
if key != story {
continue
}
match scan.root() {
Some(node) =>
return Some({
byte_start: node.byte_start,
byte_end: node.byte_end,
content_start: node.content_start,
close_tag_start: node.close_tag_start,
})
None => return None
}
}
None
}
///|
/// The zip entry holding the comments part (resolved by RELATIONSHIP
/// TYPE from the main part, never by filename), or None when the
/// package has no comments part yet — L1 then creates it.
pub fn DocxAnnotatedResult::comments_part(
self : DocxAnnotatedResult,
) -> String? {
self.comments_part_name
}
///|
/// The zip entry holding the commentsExtended part (resolved by
/// RELATIONSHIP TYPE), or None when the package has none — L2 then
/// creates it.
pub fn DocxAnnotatedResult::comments_extended_part(
self : DocxAnnotatedResult,
) -> String? {
self.comments_extended_part_name
}
///|
/// The span of a paragraph in ANY scanned story ("/comments" included),
/// by its story-relative ordinal path ("comment[2]/p[1]") — offsets
/// index into that story's part bytes. L2's paraId retrofit edits the
/// parent comment's LAST body paragraph through this.
pub fn DocxAnnotatedResult::story_paragraph_span(
self : DocxAnnotatedResult,
story : String,
relative_path : String,
) -> NodeSpan? {
for entry in self.index.scans {
let (key, scan) = entry
if key != story {
continue
}
for node in scan.nodes() {
if node.kind == "p" && node.path == relative_path {
return Some({
byte_start: node.byte_start,
byte_end: node.byte_end,
content_start: node.content_start,
close_tag_start: node.close_tag_start,
})
}
}
}
None
}
///|
/// The span of one text RUN in any scanned story, addressed by the
/// story key ("/body", "/header[1]", ...) and the story-relative ordinal
/// path ("p[3]/r[2]", "p[1]/hyperlink[1]/r[1]"). The scanner already
/// records run nodes with full spans in every story; this generalizes
/// the body-only lookup so template merging can rewrite header and
/// footer runs through the same byte-span contract.
pub fn DocxAnnotatedResult::story_run_span(
self : DocxAnnotatedResult,
story : String,
relative_path : String,
) -> NodeSpan? {
for entry in self.index.scans {
let (key, scan) = entry
if key != story {
continue
}
for node in scan.nodes() {
if node.kind == "r" && node.path == relative_path {
return Some({
byte_start: node.byte_start,
byte_end: node.byte_end,
content_start: node.content_start,
close_tag_start: node.close_tag_start,
})
}
}
}
None
}
///|
/// The span of one table ROW in any scanned story, addressed by the
/// story key ("/body", "/header[1]", ...) and the story-relative ordinal
/// path ("tbl[1]/tr[2]"). The scanner already records tr nodes with full
/// spans; template repetition replaces a row's byte region with filled
/// clones through this.
pub fn DocxAnnotatedResult::story_row_span(
self : DocxAnnotatedResult,
story : String,
relative_path : String,
) -> NodeSpan? {
for entry in self.index.scans {
let (key, scan) = entry
if key != story {
continue
}
for node in scan.nodes() {
if node.kind == "tr" && node.path == relative_path {
return Some({
byte_start: node.byte_start,
byte_end: node.byte_end,
content_start: node.content_start,
close_tag_start: node.close_tag_start,
})
}
}
}
None
}
///|
fn body_node_span_of_kind(
annotated : DocxAnnotatedResult,
relative_path : String,
kind : String,
) -> NodeSpan? {
for entry in annotated.index.scans {
let (story, scan) = entry
if story != "/body" {
continue
}
for node in scan.nodes() {
if node.kind == kind && node.path == relative_path {
return Some({
byte_start: node.byte_start,
byte_end: node.byte_end,
content_start: node.content_start,
close_tag_start: node.close_tag_start,
})
}
}
}
None
}
// L0 span pins: the scanner's insertion offsets, checked against
// positions COMPUTED from the source text (find), never hard-coded.
///|
fn span_source_offset(source : String, needle : String) -> Int raise {
match source.find(needle) {
Some(offset) => offset
None => fail("needle not in source: \{needle}")
}
}
///|
test "L0: paragraph spans carry content_start past a leading pPr" {
let source =
#|xy
let scan = scan_story(@utf8.encode(source))
let mut p1_content = -2
let mut p1_close = -2
let mut p2_content = -2
let mut p3_content = -2
let mut p3_close = -2
for node in scan.nodes() {
if node.kind == "p" && node.path == "p[1]" {
p1_content = node.content_start
p1_close = node.close_tag_start
}
if node.kind == "p" && node.path == "p[2]" {
p2_content = node.content_start
}
if node.kind == "p" && node.path == "p[3]" {
p3_content = node.content_start
p3_close = node.close_tag_start
}
}
// p[1]: content starts right past , at the first run.
assert_eq(p1_content, span_source_offset(source, "x"))
assert_eq(p1_close, span_source_offset(source, "y"))
// p[2] has no pPr: content starts right past the open tag.
assert_eq(p2_content, span_source_offset(source, "y"))
// p[3] is self-closing: no interior.
assert_eq(p3_content, -1)
assert_eq(p3_close, -1)
}
///|
test "L0: self-closing pPr and alternate prefixes still pin content_start" {
// A self-closing under an ALTERNATE WML prefix: URI-aware
// matching must move content_start past it all the same.
let source =
#|z
let scan = scan_story(@utf8.encode(source))
let mut content = -2
let mut close = -2
for node in scan.nodes() {
if node.kind == "p" && node.path == "p[1]" {
content = node.content_start
close = node.close_tag_start
}
}
assert_eq(content, span_source_offset(source, "z"))
assert_eq(close, span_source_offset(source, ""))
}
///|
test "L0: a nested pPr does not move an OUTER paragraph's content_start" {
// The pPr belongs to the INNER paragraph of a table nested mid-way;
// the outer paragraph's content_start must stay at its own first
// child. (Direct-parent check, not nearest-ancestor.)
let source =
#|cellafter
let scan = scan_story(@utf8.encode(source))
let mut cell_content = -2
let mut after_content = -2
for node in scan.nodes() {
if node.kind == "p" && node.path == "tbl[1]/tr[1]/tc[1]/p[1]" {
cell_content = node.content_start
}
if node.kind == "p" && node.path == "p[1]" {
after_content = node.content_start
}
}
assert_eq(cell_content, span_source_offset(source, "cell"))
assert_eq(after_content, span_source_offset(source, "after"))
}
///|
test "L0 review round 1: run spans mirror paragraph spans (rPr, self-closing)" {
let source =
#|bold
let scan = scan_story(@utf8.encode(source))
let mut r1_content = -2
let mut r1_close = -2
let mut r2_content = -2
for node in scan.nodes() {
if node.kind == "r" && node.path == "p[1]/r[1]" {
r1_content = node.content_start
r1_close = node.close_tag_start
}
if node.kind == "r" && node.path == "p[1]/r[2]" {
r2_content = node.content_start
}
}
// Content starts past the leading rPr (CT_R: rPr first).
assert_eq(r1_content, span_source_offset(source, "bold"))
assert_eq(r1_close, span_source_offset(source, ""))
// The self-closing has no interior.
assert_eq(r2_content, -1)
}
///|
test "L1: story roots project with insertion offsets" {
let source =
#|x
let scan = scan_story(@utf8.encode(source))
guard scan.root() is Some(root)
inspect(root.kind, content="comments")
assert_eq(root.close_tag_start, span_source_offset(source, ""))
assert_eq(root.content_start, span_source_offset(source, "",
),
)
guard empty.root() is Some(empty_root)
inspect(empty_root.content_start, content="-1")
}