Compare commits

...

4 Commits

Author SHA1 Message Date
zhanghuanxu
44be8c986f fix(slides): close text-overlap false negatives and unify z-order checks
Fix missed overflow and occlusion cases in xml_text_overlap_lint: CJK
ambiguous-width and percent glyph width estimation, chart-vs-text
occlusion, full-canvas background-image exemption, and severity masking
in the width/height overflow dedupe. Consolidate the scattered raw
paint-order comparisons into is_drawn_behind / is_drawn_in_front_of so
stacking direction is decided in one place, with contract tests that
turn red if the fixes are reverted.
2026-07-31 18:30:39 +08:00
zhanghuanxu
a849ac3449 fix(slides): detect width-induced text wrap in xml_text_overlap_lint
The height-only check (text_may_overflow_shape) misses shapes that wrap
because their box is too narrow, not too short. Added a new width-axis
detector that:

- Flags single-line short labels/metrics whose estimated width exceeds
  the content box (0.85 risk band for latin runs, 1.18 tolerance for
  plain metrics, exact fit for pure CJK).
- Works independently of autoFit (shape-auto-fit only grows height).
- Preserves internal whitespace (e.g. "autofix      87%") so spaces
  are not collapsed away.
- Deduplicates with the height check so the same shape is not
  double-reported under the shared text_may_overflow_shape code.

The two axes share code="text_may_overflow_shape" and are distinguished
by overflow_axis="height"|"width". Regression test covers all three
real false-negative cases (bMP/bMp/bMm) plus negative controls.
2026-07-31 18:30:39 +08:00
zhanghuanxu
47a46658c8 fix(slides): remove order exemption in image-text occlusion detection
The order-based skip (`image.order <= text.order`) allowed images that
appear before text in XML to silently cover text glyphs. Remove it so
any geometric overlap is reported regardless of XML element order.

Also update the error hint to no longer suggest reordering XML as a
fix, since that no longer works.

Add a regression test verifying the new behavior.
2026-07-31 18:30:39 +08:00
zhanghuanxu
11f34b3e85 fix(slides): detect text-line overlap in xml_text_overlap_lint 2026-07-31 18:30:39 +08:00
2 changed files with 1283 additions and 64 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1004,9 +1004,17 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
overlap_pairs = {tuple(issue["elements"]) for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 2)
# Two caption/label pairs overlap; blV also trips the width-wrap rule (its 15-char
# caption renders wider than its 150px box), which is an intentional error here.
self.assertEqual(result["summary"]["error_count"], 3)
self.assertIn(("blY", "blV"), overlap_pairs)
self.assertIn(("blQ", "blS"), overlap_pairs)
wrap_ids = {
issue["elements"][0]
for issue in result["slides"][0]["issues"]
if issue.get("overflow_axis") == "width"
}
self.assertEqual(wrap_ids, {"blV"})
def test_lint_xml_detects_horizontal_text_overflow_across_declared_box_gap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -1119,6 +1127,42 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(overflowing_issue["overflow"], 30)
self.assertIn('wrap="true" autoFit="normal-auto-fit"', overflowing_issue["message"])
def test_lint_xml_detects_short_label_that_wraps_by_width(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="near-fit" type="text" topLeftX="40" topLeftY="40" width="176" height="96">
<content textType="sub-headline" fontSize="32" fontFamily="思源黑体" bold="true"><p>Slides 87% </p></content>
</shape>
<shape id="under-measured" type="text" topLeftX="40" topLeftY="160" width="136" height="90">
<content fontSize="30" fontFamily="黑体"><p>Docs 99%</p></content>
</shape>
<shape id="auto-fit-spaced" type="text" topLeftX="300" topLeftY="40" width="227" height="96">
<content textType="sub-headline" fontSize="32" fontFamily="思源黑体" bold="true" autoFit="shape-auto-fit"><p>autofix 87% </p></content>
</shape>
<shape id="no-wrap-label" type="text" topLeftX="300" topLeftY="160" width="136" height="90">
<content fontSize="30" fontFamily="黑体" wrap="false"><p>Docs 99%</p></content>
</shape>
<shape id="comfortable" type="text" topLeftX="600" topLeftY="40" width="300" height="60">
<content fontSize="24" fontFamily="思源黑体"><p>OK</p></content>
</shape>
</data>
</slide>
"""
)
wrap_issues = [
issue for issue in result["slides"][0]["issues"] if issue.get("overflow_axis") == "width"
]
wrap_ids = {issue["elements"][0] for issue in wrap_issues}
# The three real false-negatives are caught, independent of autoFit and collapsed spaces.
self.assertEqual(wrap_ids, {"near-fit", "under-measured", "auto-fit-spaced"})
self.assertTrue(all(issue["level"] == "error" for issue in wrap_issues))
self.assertTrue(all(issue["code"] == "text_may_overflow_shape" for issue in wrap_issues))
# wrap="false" opts a run out; a label that comfortably fits is not flagged.
self.assertNotIn("no-wrap-label", wrap_ids)
self.assertNotIn("comfortable", wrap_ids)
def test_lint_xml_uses_fixed_line_spacing_for_text_height_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -1203,6 +1247,28 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_reports_labeled_short_metric_when_it_wraps(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="sheet-success" type="text" topLeftX="520" topLeftY="385" width="180" height="50">
<content textType="headline" fontSize="32" bold="true" autoFit="no-auto-fit">
<p>Sheet 98.5%</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["sheet-success"])
def test_lint_xml_reports_plain_short_metric_when_it_wraps(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -1223,6 +1289,97 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["plain-age"])
def test_lint_xml_reports_cjk_credit_with_em_dashes_wrapping_narrow_box(self) -> None:
# "—— 李白" in a tight author-credit box wraps in the renderer because the two em-dashes render
# full-width inside a CJK run (slides p1: bMW). unicodedata marks em-dash as ambiguous width, so
# a naive Latin-punctuation estimate under-reports the line and misses the wrap. The width check
# must treat ambiguous glyphs as full-width in CJK context (Bucket A4).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="credit" type="text" topLeftX="66" topLeftY="124" width="46" height="18">
<content fontSize="12"><p>—— 李白</p></content>
</shape>
</data>
</slide>
"""
)
wrap_issues = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["credit"]
]
# Promoting the em-dashes to full-width makes the run too wide for the box; the renderer then
# wraps it to two lines that also overflow the 18px height, so either the width or the height
# detector may surface it first -- the contract is that the credit is flagged, not which axis.
self.assertEqual(len(wrap_issues), 1)
self.assertIn(wrap_issues[0]["overflow_axis"], {"width", "height"})
def test_lint_xml_keeps_latin_en_dash_range_narrow(self) -> None:
# The ambiguous-width promotion is context-gated: an en-dash in a pure-Latin run ("20202023")
# stays half-width, so a comfortably-sized box must not be reported. Guards A4 from over-firing
# by inflating every dash to full-width regardless of surrounding script.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="range" type="text" topLeftX="80" topLeftY="80" width="140" height="30">
<content fontSize="14"><p>20202023</p></content>
</shape>
</data>
</slide>
"""
)
wrap_issues = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(wrap_issues, [])
def test_lint_xml_reports_percent_heavy_run_overflowing_by_full_width_glyph(self) -> None:
# "%" is Unicode half-width (Na) but renders near full-width, so a percentage-heavy run wraps to
# more lines than a naive punct-coefficient estimate and overflows its box height (slides p3:
# bhU "Docs 99%Docs 99%Docs 99%%1"). Measuring "%" at its true advance is what surfaces this.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="metrics" type="text" topLeftX="587" topLeftY="60" width="227" height="100">
<content fontSize="30" autoFit="no-auto-fit"><p>Docs 99%Docs 99%Docs 99%%1</p></content>
</shape>
</data>
</slide>
"""
)
overflow = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["metrics"]
]
self.assertEqual(len(overflow), 1)
def test_lint_xml_marginal_height_warning_does_not_mask_width_error(self) -> None:
# A short "Slides 87%" label sized so its wrapped two lines graze the box height by <1px yields a
# height *warning*, while the same run is genuinely too wide -> a width *error*. The width error
# must still surface: a marginal height warning must not suppress it via already_flagged_ids
# (slides p3: bMP/bhd). The run is reported once, at error level.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="label" type="text" topLeftX="244" topLeftY="120" width="176" height="79">
<content fontSize="32" bold="true" autoFit="no-auto-fit"><p>Slides 87%</p></content>
</shape>
</data>
</slide>
"""
)
reports = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["label"]
]
self.assertEqual(len(reports), 1)
self.assertEqual(reports[0]["level"], "error")
def test_lint_xml_allows_centered_short_label_near_fit_as_single_line(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -1716,10 +1873,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<img src="tok" topLeftX="-120" topLeftY="20" width="360" height="360"/>
<shape type="text" topLeftX="40" topLeftY="80" width="180" height="80">
<shape type="text" topLeftX="300" topLeftY="80" width="180" height="80">
<content textType="title" fontSize="44"><p>Title</p></content>
</shape>
<shape type="text" topLeftX="40" topLeftY="120" width="180" height="40">
<shape type="text" topLeftX="300" topLeftY="170" width="180" height="40">
<content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
</shape>
</data>
@@ -1893,6 +2050,32 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
]
self.assertEqual(len(crossing), 1)
def test_lint_xml_reports_horizontal_line_inside_wide_line_spacing_span(self) -> None:
# 3 lines of fontSize 20 at multiple:1.8 give a real 92px glyph span, but the flat
# font_size*1.2 approximation is only 72px. Both boxes centre in the 200px shape, so the flat
# eroded box is ~[246,314] while the spacing-aware eroded box is ~[236,324]. A rule at y=240
# lands in that top margin -- inside the real glyph rows yet outside the flat box -- so it only
# reports once the line-crossing path uses the spacing-aware height (Bucket C, slides p8/p10).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="poem" type="text" topLeftX="80" topLeftY="180" width="360" height="200">
<content fontSize="20" lineSpacing="multiple:1.8"><p>第一行诗句文字</p><p>第二行诗句文字</p><p>第三行诗句文字</p></content>
</shape>
<line id="rule" startX="80" startY="240" endX="220" endY="240">
<border color="rgb(0, 0, 0)" width="3"/>
</line>
</data>
</slide>
"""
)
crossing = [
issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"rule", "poem"}
]
self.assertEqual(len(crossing), 1)
self.assertEqual(crossing[0]["code"], "bbox_overlap")
def test_lint_xml_reports_diagonal_line_crossing_text_block(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -2321,6 +2504,266 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_reports_rotated_text_colliding_with_horizontal_text(self) -> None:
# A 270-rotated label sweeps a vertical footprint that overlaps a nearby horizontal label. With
# rotation-aware glyph boxes the collision is detectable, and because the runs are not parallel
# the overlap ratio is tiny so the absolute-area fallback must flag it (slides p6, Bucket D+E).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="flat" type="text" topLeftX="240" topLeftY="200" width="64" height="24">
<content fontSize="16"><p>文字碰撞</p></content>
</shape>
<shape id="spun" type="text" topLeftX="272" topLeftY="232" width="64" height="24" rotation="270">
<content fontSize="16"><p>文字碰撞</p></content>
</shape>
</data>
</slide>
"""
)
collisions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "bbox_overlap" and set(issue["elements"]) == {"flat", "spun"}
]
self.assertEqual(len(collisions), 1)
def test_lint_xml_still_suppresses_coincident_shadow_text_overlay(self) -> None:
# A drop-shadow duplicate offset by a pixel is an intentional overlay; the coincidence check
# must keep suppressing it even though the text is identical (guards the E1 tightening).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="shadow" type="text" topLeftX="200" topLeftY="200" width="200" height="40">
<content fontSize="20"><p>标题文字</p></content>
</shape>
<shape id="fill" type="text" topLeftX="202" topLeftY="202" width="200" height="40">
<content fontSize="20"><p>标题文字</p></content>
</shape>
</data>
</slide>
"""
)
collisions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "bbox_overlap" and set(issue["elements"]) == {"shadow", "fill"}
]
self.assertEqual(collisions, [])
def test_lint_xml_reports_text_overflowing_background_container(self) -> None:
# Text anchored inside a background card whose glyph box spills past the card's bottom edge has
# outgrown the box the author sized for it (slides p7). The card is drawn first (lower z-order),
# so it is the container; the text must surface as text_overflows_container (Bucket B).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="200" topLeftY="200" width="120" height="40">
<fill><fillColor color="rgba(230,230,230,1)"/></fill>
</shape>
<shape id="body" type="text" topLeftX="205" topLeftY="205" width="110" height="120">
<content fontSize="16"><p>第一行</p><p>第二行</p><p>第三行</p></content>
</shape>
</data>
</slide>
"""
)
overflow = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "text_overflows_container" and set(issue["elements"]) == {"body", "card"}
]
self.assertEqual(len(overflow), 1)
self.assertGreater(overflow[0]["overflow"]["bottom"], 4)
def test_lint_xml_ignores_text_fitting_inside_background_container(self) -> None:
# Text whose glyph box stays inside its background card is fine; the container rule must stay
# silent so tightly-fitted-but-valid cards are not falsely reported.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="200" topLeftY="200" width="200" height="120">
<fill><fillColor color="rgba(230,230,230,1)"/></fill>
</shape>
<shape id="body" type="text" topLeftX="210" topLeftY="210" width="180" height="40">
<content fontSize="14"><p>短文本</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("text_overflows_container", codes)
def test_lint_xml_reports_free_text_shape_overlapping_table_grid(self) -> None:
# A free-floating text shape whose glyph box lands on top of a sibling table occludes the cell
# contents (slides p4). The table renders its own text; a stray shape over the grid is an
# accidental overlay, so it must surface as table_covers_text (Bucket B).
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<table id="grid" topLeftX="200" topLeftY="200" width="400" height="150">
<tr><td><content><p>A</p></content></td></tr>
</table>
<shape id="stray" type="text" topLeftX="260" topLeftY="240" width="120" height="30">
<content fontSize="16"><p>覆盖表格</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
occlusions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "table_covers_text" and set(issue["elements"]) == {"grid", "stray"}
]
self.assertEqual(len(occlusions), 1)
def test_lint_xml_ignores_table_with_only_cell_text(self) -> None:
# Cell text is part of the table's own layout and is never extracted as a standalone shape, so
# a table alone must not self-report table_covers_text (guards against a runaway detector).
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<table id="solo" topLeftX="200" topLeftY="200" width="400" height="150">
<tr><td><content><p>Score</p></content></td></tr>
</table>
</data>
</slide>
</presentation>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("table_covers_text", codes)
def test_lint_xml_reports_free_text_shape_overlapping_chart(self) -> None:
# A free-floating text shape whose glyph box lands on top of a sibling chart occludes the chart's
# generated labels and legend (slides p5: a headline dropped onto a pie chart's ring). The chart
# renders its own text; a stray shape over the plot area is an accidental overlay, so it must
# surface as chart_covers_text (Bucket B3).
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<chart id="pie" topLeftX="200" topLeftY="60" width="420" height="420">
<chartData><dim1><chartField name="p">A,B</chartField></dim1></chartData>
</chart>
<shape id="stray" type="text" topLeftX="360" topLeftY="120" width="120" height="40">
<content fontSize="32"><p>abc 99%</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
occlusions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "chart_covers_text" and set(issue["elements"]) == {"pie", "stray"}
]
self.assertEqual(len(occlusions), 1)
def test_lint_xml_ignores_chart_not_overlapping_text(self) -> None:
# A chart and a text shape that sit side by side without their glyph boxes touching must not
# report chart_covers_text (guards the detector from firing on mere co-existence).
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<chart id="pie" topLeftX="40" topLeftY="60" width="300" height="300">
<chartData><dim1><chartField name="p">A,B</chartField></dim1></chartData>
</chart>
<shape id="caption" type="text" topLeftX="600" topLeftY="80" width="200" height="40">
<content fontSize="16"><p>Sales breakdown</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("chart_covers_text", codes)
def test_lint_xml_reports_auto_fit_title_growing_onto_body_below(self) -> None:
# A shape-auto-fit title sized for one line wraps to two, growing downward past its authored box
# onto the body text beneath it (slides p9). shape-auto-fit only means the box grows to fit, so
# the grown glyph height -- not the authored height -- is what collides. The body is a tall
# multi-line block so the overlap covers <30% of it: the generic text-text check cannot catch
# this, only the dedicated auto-fit growth detector can (Bucket A3 / auto-fit growth).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="80" topLeftY="20" width="480" height="36">
<content fontSize="24" autoFit="shape-auto-fit"><p>02. | Literature Review - International Research</p></content>
</shape>
<shape id="body" type="text" topLeftX="80" topLeftY="60" width="480" height="200">
<content fontSize="15" verticalAlign="top"><p>1. Marxist Perspective</p><p>Line two of body copy</p><p>Line three of body copy</p><p>Line four of body copy</p><p>Line five of body copy</p><p>Line six of body copy</p></content>
</shape>
</data>
</slide>
"""
)
collisions = [
issue for issue in result["slides"][0]["errors"]
if issue["code"] == "bbox_overlap" and set(issue["elements"]) == {"title", "body"}
]
self.assertEqual(len(collisions), 1)
def test_lint_xml_ignores_auto_fit_title_with_space_below(self) -> None:
# An identical wrapping auto-fit title with an empty gap below it grows harmlessly; the check
# must stay silent so ordinary auto-fit growth is not flagged (guards the grown-region area gate).
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="80" topLeftY="20" width="480" height="36">
<content fontSize="24" autoFit="shape-auto-fit"><p>02. | Literature Review - International Research</p></content>
</shape>
<shape id="body" type="text" topLeftX="80" topLeftY="300" width="480" height="200">
<content fontSize="15"><p>1. Marxist Perspective</p></content>
</shape>
</data>
</slide>
"""
)
collisions = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "bbox_overlap" and set(issue["elements"]) == {"title", "body"}
]
self.assertEqual(collisions, [])
def test_lint_xml_does_not_treat_divider_rule_as_text_background_container(self) -> None:
# A thin horizontal rule under a title is a divider, not a container. Owning a title's grown
# glyph box to a 3px rule and reporting it as text_overflows_container is a false positive
# (slides p9); the line-like guard must keep the divider out of the container candidate set.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="rule" type="rect" topLeftX="40" topLeftY="60" width="880" height="3">
<fill><fillColor color="rgba(40,60,120,1)"/></fill>
</shape>
<shape id="title" type="text" topLeftX="80" topLeftY="20" width="480" height="36">
<content fontSize="24" autoFit="shape-auto-fit"><p>02. | Literature Review - International Research</p></content>
</shape>
</data>
</slide>
"""
)
container_hits = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "text_overflows_container" and "rule" in issue["elements"]
]
self.assertEqual(container_hits, [])
def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -2459,6 +2902,79 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["info_count"], 1)
def test_lint_xml_reports_image_text_overlap_even_when_image_precedes_text_in_xml_order(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<img id="image" src="token" topLeftX="120" topLeftY="120" width="120" height="60"/>
<shape id="text" type="text" topLeftX="100" topLeftY="100" width="220" height="90">
<content fontSize="28" lineSpacing="fixed:34" wrap="false"><p>Quarterly Plan</p></content>
</shape>
</data></slide>
"""
)
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_covers_text")
self.assertEqual(issue["elements"], ["image", "text"])
self.assertIn("no longer overlaps the text glyph area", issue["hint"])
self.assertEqual(result["summary"]["error_count"], 1)
def test_lint_xml_exempts_full_canvas_background_image_behind_text(self) -> None:
# A full-bleed image at the bottom of the z-order is the slide backdrop; text rendered on top of
# it is never occluded (slides p9: bBo fills the whole canvas under the content). It must not be
# reported as image_covers_text (Bucket B5 background-image false positive).
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<img id="backdrop" src="token" topLeftX="0" topLeftY="0" width="960" height="540"/>
<shape id="text" type="text" topLeftX="100" topLeftY="100" width="400" height="60">
<content fontSize="28"><p>On the backdrop</p></content>
</shape>
</data></slide>
</presentation>
"""
)
codes = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "image_covers_text" and "backdrop" in issue["elements"]
]
self.assertEqual(codes, [])
def test_lint_xml_reports_full_canvas_image_drawn_above_text(self) -> None:
# The exemption is z-order aware: a full-canvas image drawn *after* (above) the text really does
# cover it, so it must still be flagged. Guards the backdrop exemption from swallowing real
# occlusions where the image is on top.
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<shape id="text" type="text" topLeftX="100" topLeftY="100" width="400" height="60">
<content fontSize="28"><p>Under the cover</p></content>
</shape>
<img id="cover" src="token" topLeftX="0" topLeftY="0" width="960" height="540"/>
</data></slide>
</presentation>
"""
)
codes = [
issue for issue in result["slides"][0]["issues"]
if issue["code"] == "image_covers_text" and set(issue["elements"]) == {"cover", "text"}
]
self.assertEqual(len(codes), 1)
def test_stacking_helpers_agree_on_paint_order(self) -> None:
lower = {"order": 1}
upper = {"order": 3}
same = {"order": 3}
# is_drawn_behind and is_drawn_in_front_of are strict and mutually exclusive inverses.
self.assertTrue(xml_text_overlap_lint.is_drawn_behind(lower, upper))
self.assertFalse(xml_text_overlap_lint.is_drawn_in_front_of(lower, upper))
self.assertTrue(xml_text_overlap_lint.is_drawn_in_front_of(upper, lower))
self.assertFalse(xml_text_overlap_lint.is_drawn_behind(upper, lower))
# Equal order is neither behind nor in front, so an equal-order sibling never occludes.
self.assertFalse(xml_text_overlap_lint.is_drawn_behind(same, upper))
self.assertFalse(xml_text_overlap_lint.is_drawn_in_front_of(same, upper))
class XmlTextOverlapLintDensityTest(unittest.TestCase):
def test_lint_xml_blocks_blank_slide(self) -> None: