Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 26 additions & 31 deletions onenote_export/parser/content_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,17 +258,12 @@ def _walk_oe(oe_id: str) -> None:
if group:
for g in group:
_emit(g)
elif (
orphan_idx < len(orphans)
and oe_id not in oes_with_children
):
elif orphan_idx < len(orphans) and oe_id not in oes_with_children:
# Contentless leaf OE — assign next orphan.
_emit(orphans[orphan_idx])
orphan_idx += 1

child_refs = oe_obj.properties.get(
"ElementChildNodesOfVersionHistory", []
)
child_refs = oe_obj.properties.get("ElementChildNodesOfVersionHistory", [])
if isinstance(child_refs, str):
child_refs = [child_refs]
if isinstance(child_refs, list):
Expand All @@ -281,9 +276,7 @@ def _walk_oe(oe_id: str) -> None:
# ordered by original index. Nodes with a vert value follow, sorted
# ascending (top-to-bottom on the page).
outline_nodes = [
(i, obj)
for i, obj in enumerate(objects)
if obj.obj_type == _OUTLINE_NODE
(i, obj) for i, obj in enumerate(objects) if obj.obj_type == _OUTLINE_NODE
]

def _node_sort_key(
Expand All @@ -299,9 +292,7 @@ def _node_sort_key(

for _, node in outline_nodes:
_emit(node)
child_refs = node.properties.get(
"ElementChildNodesOfVersionHistory", []
)
child_refs = node.properties.get("ElementChildNodesOfVersionHistory", [])
if isinstance(child_refs, str):
child_refs = [child_refs]
if isinstance(child_refs, list):
Expand Down Expand Up @@ -697,7 +688,7 @@ def _extract_rich_text(

# Check for HYPERLINK field codes embedded in the text
# (collapsible sections store URLs as field codes in the text itself)
has_field_code = "\uFDDF" in text or "\uFDF3" in text
has_field_code = "\ufddf" in text or "\ufdf3" in text
wz_hyperlink = _clean_text(str(props.get("WzHyperlinkUrl", "")))

# Check if this is title text
Expand All @@ -714,8 +705,24 @@ def _extract_rich_text(
for i, (seg_text, seg_url) in enumerate(segments):
# First segment without a field-code URL inherits WzHyperlinkUrl
url = seg_url if seg_url else (wz_hyperlink if i == 0 else "")
runs.append(TextRun(
text=seg_text,
runs.append(
TextRun(
text=seg_text,
bold=bold,
italic=italic,
underline=underline,
strikethrough=strikethrough,
superscript=superscript,
subscript=subscript,
font=font,
font_size=font_size,
hyperlink_url=url,
)
)
else:
runs.append(
TextRun(
text=text,
bold=bold,
italic=italic,
underline=underline,
Expand All @@ -724,21 +731,9 @@ def _extract_rich_text(
subscript=subscript,
font=font,
font_size=font_size,
hyperlink_url=url,
))
else:
runs.append(TextRun(
text=text,
bold=bold,
italic=italic,
underline=underline,
strikethrough=strikethrough,
superscript=superscript,
subscript=subscript,
font=font,
font_size=font_size,
hyperlink_url=wz_hyperlink,
))
hyperlink_url=wz_hyperlink,
)
)

indent_level = 0
list_type = ""
Expand Down
76 changes: 44 additions & 32 deletions tests/test_content_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,8 +489,7 @@ def test_extracts_hyperlink_from_field_code(self):
identity="1",
properties={
"RichEditTextUnicode": (
'\uFDDFHYPERLINK "mailto:user@example.com"'
"User Name (Accepted)\x00"
'\ufddfHYPERLINK "mailto:user@example.com"User Name (Accepted)\x00'
),
},
)
Expand All @@ -506,8 +505,7 @@ def test_extracts_hyperlink_from_https_field_code(self):
identity="1",
properties={
"RichEditTextUnicode": (
'\uFDDFHYPERLINK "https://example.com/path"'
"Link to Document\x00"
'\ufddfHYPERLINK "https://example.com/path"Link to Document\x00'
),
},
)
Expand All @@ -521,13 +519,13 @@ class TestParseHyperlinkFieldCodes:
"""Tests for _parse_hyperlink_field_codes."""

def test_mailto_url(self):
text = '\uFDDFHYPERLINK "mailto:user@example.com"User Name\x00'
text = '\ufddfHYPERLINK "mailto:user@example.com"User Name\x00'
segments = _parse_hyperlink_field_codes(text)
assert len(segments) == 1
assert segments[0] == ("User Name", "mailto:user@example.com")

def test_https_url(self):
text = '\uFDDFHYPERLINK "https://example.com/path?q=1"Click Here\x00'
text = '\ufddfHYPERLINK "https://example.com/path?q=1"Click Here\x00'
segments = _parse_hyperlink_field_codes(text)
assert len(segments) == 1
assert segments[0] == ("Click Here", "https://example.com/path?q=1")
Expand All @@ -538,13 +536,13 @@ def test_no_field_code(self):
assert segments == [("Just regular text", "")]

def test_alternate_marker_fdf3(self):
text = '\uFDF3HYPERLINK "https://example.com"Link Text\x00'
text = '\ufdf3HYPERLINK "https://example.com"Link Text\x00'
segments = _parse_hyperlink_field_codes(text)
assert len(segments) == 1
assert segments[0] == ("Link Text", "https://example.com")

def test_display_text_with_parentheses(self):
text = '\uFDDFHYPERLINK "mailto:a@b.com"Name (Accepted Meeting)\x00'
text = '\ufddfHYPERLINK "mailto:a@b.com"Name (Accepted Meeting)\x00'
segments = _parse_hyperlink_field_codes(text)
assert segments[0] == ("Name (Accepted Meeting)", "mailto:a@b.com")

Expand All @@ -554,7 +552,7 @@ def test_empty_string(self):

def test_mixed_text_and_field_code(self):
"""Text with prefix before a field code produces two segments."""
text = 'Meeting options | \uFDDFHYPERLINK "https://example.com"Reset PIN'
text = 'Meeting options | \ufddfHYPERLINK "https://example.com"Reset PIN'
segments = _parse_hyperlink_field_codes(text)
assert len(segments) == 2
assert segments[0] == ("Meeting options |", "")
Expand All @@ -563,8 +561,8 @@ def test_mixed_text_and_field_code(self):
def test_two_field_codes_in_one_text(self):
"""Two field codes in one text node produce three segments."""
text = (
'Prefix: \uFDDFHYPERLINK "https://a.com"Link A | '
'\uFDDFHYPERLINK "https://b.com"Link B'
'Prefix: \ufddfHYPERLINK "https://a.com"Link A | '
'\ufddfHYPERLINK "https://b.com"Link B'
)
segments = _parse_hyperlink_field_codes(text)
assert len(segments) == 3
Expand Down Expand Up @@ -622,12 +620,14 @@ def test_no_orphans_returned_unchanged(self):
"""If no content before the first structural element, return as-is."""
objs = [
self._make_obj(
"jcidOutlineNode", "ON1",
"jcidOutlineNode",
"ON1",
ElementChildNodesOfVersionHistory=["OE1"],
),
self._make_obj("jcidOutlineElementNode", "OE1"),
self._make_obj(
"jcidRichTextOENode", "RT1",
"jcidRichTextOENode",
"RT1",
RichEditTextUnicode="Hello",
),
self._make_obj("jcidOutlineElementNode", "OE2"),
Expand All @@ -638,37 +638,42 @@ def test_no_orphans_returned_unchanged(self):
def test_orphan_relocated_after_contentless_oe(self):
"""Orphaned content before first OE should move after its parent OE."""
orphan_rt = self._make_obj(
"jcidRichTextOENode", "RT-orphan",
"jcidRichTextOENode",
"RT-orphan",
RichEditTextUnicode="Note 4",
)
outline_node = self._make_obj(
"jcidOutlineNode", "ON1",
"jcidOutlineNode",
"ON1",
ElementChildNodesOfVersionHistory=["OE-parent", "OE-child"],
)
oe_parent = self._make_obj(
"jcidOutlineElementNode", "OE-parent",
"jcidOutlineElementNode",
"OE-parent",
ElementChildNodesOfVersionHistory=["OE-nested"],
)
rt_parent = self._make_obj(
"jcidRichTextOENode", "RT-parent",
"jcidRichTextOENode",
"RT-parent",
RichEditTextUnicode="Notes",
)
# OE-nested is the contentless OE that should receive the orphan
oe_nested = self._make_obj("jcidOutlineElementNode", "OE-nested")
oe_child = self._make_obj("jcidOutlineElementNode", "OE-child")
rt_child = self._make_obj(
"jcidRichTextOENode", "RT-child",
"jcidRichTextOENode",
"RT-child",
RichEditTextUnicode="Some text",
)

objects = [
orphan_rt, # [0] orphaned content
outline_node, # [1] OutlineNode
oe_nested, # [2] out-of-place OE (no content)
oe_parent, # [3] OE with content
rt_parent, # [4] content for OE-parent
oe_child, # [5] OE with content
rt_child, # [6] content for OE-child
orphan_rt, # [0] orphaned content
outline_node, # [1] OutlineNode
oe_nested, # [2] out-of-place OE (no content)
oe_parent, # [3] OE with content
rt_parent, # [4] content for OE-parent
oe_child, # [5] OE with content
rt_child, # [6] content for OE-child
]

result = _reorder_by_outline_hierarchy(objects)
Expand All @@ -685,17 +690,20 @@ def test_orphan_relocated_after_contentless_oe(self):
def test_already_correct_order_preserved(self):
"""Objects in correct hierarchy order should produce identical output."""
outline_node = self._make_obj(
"jcidOutlineNode", "ON1",
"jcidOutlineNode",
"ON1",
ElementChildNodesOfVersionHistory=["OE1", "OE2"],
)
oe1 = self._make_obj("jcidOutlineElementNode", "OE1")
rt1 = self._make_obj(
"jcidRichTextOENode", "RT1",
"jcidRichTextOENode",
"RT1",
RichEditTextUnicode="First",
)
oe2 = self._make_obj("jcidOutlineElementNode", "OE2")
rt2 = self._make_obj(
"jcidRichTextOENode", "RT2",
"jcidRichTextOENode",
"RT2",
RichEditTextUnicode="Second",
)

Expand All @@ -707,20 +715,24 @@ def test_already_correct_order_preserved(self):
def test_outline_nodes_sorted_by_vert_position(self):
"""Nodes without vert come first, then ascending vert value."""
orphan = self._make_obj(
"jcidRichTextOENode", "RT-orphan",
"jcidRichTextOENode",
"RT-orphan",
RichEditTextUnicode="orphan",
)
node_no_vert = self._make_obj(
"jcidOutlineNode", "ON-title",
"jcidOutlineNode",
"ON-title",
ElementChildNodesOfVersionHistory=["OE-title"],
)
oe_title = self._make_obj("jcidOutlineElementNode", "OE-title")
rt_title = self._make_obj(
"jcidRichTextOENode", "RT-title",
"jcidRichTextOENode",
"RT-title",
RichEditTextUnicode="Title",
)
node_vert = self._make_obj(
"jcidOutlineNode", "ON-body",
"jcidOutlineNode",
"ON-body",
OffsetFromParentVert=200,
ElementChildNodesOfVersionHistory=["OE-body"],
)
Expand Down
5 changes: 4 additions & 1 deletion tests/test_eval_notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ class TestHierarchy:
("Zeta-Sub-2a", 3), # level-3 subpage
("Alpha", 1),
("Alpha-Sub-1", 2),
("Mike", 1), # leaf; last despite "M" — order is display order, not alphabetical
(
"Mike",
1,
), # leaf; last despite "M" — order is display order, not alphabetical
]

def test_order_and_levels(self):
Expand Down
30 changes: 9 additions & 21 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ def test_no_raw_hyperlink_field_codes_in_text(self, section3_note1):
for elem in section3_note1.elements:
if isinstance(elem, RichText):
for run in elem.runs:
assert "\uFDDF" not in run.text, (
assert "\ufddf" not in run.text, (
f"Raw field code marker in text: {run.text!r}"
)
assert "HYPERLINK" not in run.text, (
Expand Down Expand Up @@ -593,8 +593,8 @@ def test_markdown_has_proper_link_syntax(self, section3_note1, tmp_path):
converter = MarkdownConverter(tmp_path)
md = converter.render_page(section3_note1)
# Should not contain raw field codes
assert "\uFDDF" not in md, f"Raw field code in markdown:\n{md}"
assert "\uFDF3" not in md, f"Raw field code in markdown:\n{md}"
assert "\ufddf" not in md, f"Raw field code in markdown:\n{md}"
assert "\ufdf3" not in md, f"Raw field code in markdown:\n{md}"
# Should contain proper markdown links
link_pattern = re.compile(r"\[.+?\]\(.+?\)")
links = link_pattern.findall(md)
Expand Down Expand Up @@ -658,9 +658,7 @@ def test_note4_not_first_element(self, section3_note1):
def test_notes_heading_before_note1_bullet(self, section3_note1):
"""'Notes' heading should appear before 'Note 1 - Meeting Notes' bullet."""
texts = self._all_texts(section3_note1)
notes_idx = next(
(i for i, t in enumerate(texts) if t.strip() == "Notes"), None
)
notes_idx = next((i for i, t in enumerate(texts) if t.strip() == "Notes"), None)
note1_idx = next(
(i for i, t in enumerate(texts) if "Note 1 - Meeting" in t), None
)
Expand All @@ -677,32 +675,22 @@ def test_note1_bullet_before_note2(self, section3_note1):
note1_idx = next(
(i for i, t in enumerate(texts) if "Note 1 - Meeting" in t), None
)
note2_idx = next(
(i for i, t in enumerate(texts) if "Note 2" in t), None
)
note2_idx = next((i for i, t in enumerate(texts) if "Note 2" in t), None)
assert note1_idx is not None and note2_idx is not None
assert note1_idx < note2_idx

def test_note2_before_note3(self, section3_note1):
"""'Note 2' should appear before 'Note 3'."""
texts = self._all_texts(section3_note1)
note2_idx = next(
(i for i, t in enumerate(texts) if "Note 2" in t), None
)
note3_idx = next(
(i for i, t in enumerate(texts) if "Note 3" in t), None
)
note2_idx = next((i for i, t in enumerate(texts) if "Note 2" in t), None)
note3_idx = next((i for i, t in enumerate(texts) if "Note 3" in t), None)
assert note2_idx is not None and note3_idx is not None
assert note2_idx < note3_idx

def test_note3_before_note4(self, section3_note1):
"""'Note 3' should appear before 'Note 4'."""
texts = self._all_texts(section3_note1)
note3_idx = next(
(i for i, t in enumerate(texts) if "Note 3" in t), None
)
note4_idx = next(
(i for i, t in enumerate(texts) if "Note 4" in t), None
)
note3_idx = next((i for i, t in enumerate(texts) if "Note 3" in t), None)
note4_idx = next((i for i, t in enumerate(texts) if "Note 4" in t), None)
assert note3_idx is not None and note4_idx is not None
assert note3_idx < note4_idx
Loading