Skip to content

Replace CairoSVG with resvg-py and migrate PDF generation to reportlab - #2136

Merged
moshemorad merged 7 commits into
masterfrom
claude/lgpl-distribution-obligations-40z68c
Aug 11, 2026
Merged

Replace CairoSVG with resvg-py and migrate PDF generation to reportlab#2136
moshemorad merged 7 commits into
masterfrom
claude/lgpl-distribution-obligations-40z68c

Conversation

@moshemorad

Copy link
Copy Markdown
Contributor

Summary

This PR replaces the LGPL-licensed CairoSVG library with the MIT-licensed resvg-py for SVG-to-PNG rasterization, and migrates PDF report generation from fpdf2 to reportlab. These changes improve licensing compliance and provide better control over report formatting.

Key Changes

SVG Rasterization

  • Replaced CairoSVG with resvg-py for converting SVG charts to PNG
  • Updated convert_svg_to_png() in core/reporting/utils.py to use resvg-py instead of cairosvg
  • Removed libcairo2 system dependency from Dockerfile
  • Removed CairoSVG from playbooks/pyproject.toml

PDF Report Generation

  • Migrated scanReportBlock_to_fileblock() in core/sinks/transformer.py from fpdf2 to reportlab
  • Replaced FPDF-based layout with reportlab's Platypus document model (SimpleDocTemplate, Table, Paragraph, Spacer)
  • Implemented proper color handling using reportlab's Color objects with normalized RGB values
  • Added cell_markup() helper to escape and convert markdown formatting (bold and newlines) to reportlab paragraph markup
  • Improved table styling with TableStyle for borders, padding, and alignment
  • Maintained landscape A4 page orientation and similar visual layout

MS Teams Integration

  • Updated MsTeamsAdaptiveCardFilesImage to use the new convert_svg_to_png() function

Testing

  • Added comprehensive test suite in tests/test_scan_report_rendering.py with:
    • PDF extraction utility to verify report contents without external PDF libraries
    • Tests for KRR and Popeye scan report generation
    • Tests for edge cases (empty results, hostile content with special characters)
  • Added tests/test_scan_sink_gating.py to verify scan enrichments are properly converted before sink processing
  • Added tests/test_svg_conversion.py to pin SVG-to-PNG conversion behavior
  • Updated tests/test_ai_integration.py with PNG validation helpers and tests for graph tool output handling

Implementation Details

  • reportlab is imported lazily in the scan report path to avoid requiring the PDF toolchain for non-report use cases
  • PDF text extraction in tests uses a custom implementation that decompresses content streams and extracts text without external PDF libraries
  • The new implementation properly handles special characters, long text wrapping, and maintains consistent formatting across different scan types

https://claude.ai/code/session_01LTG88e6gtCnRrrf8Pgyhdo

claude added 2 commits August 10, 2026 18:49
Pin the current behavior of the two code paths that depend on LGPL-licensed
libraries (fpdf2 and CairoSVG), so they can be safely replaced or removed:

- test_scan_report_rendering.py: Transformer.scanReportBlock_to_fileblock()
  KRR/Popeye PDF generation - happy paths with content verification, block
  pass-through, empty results, and hostile cell content (previously untested)
- test_scan_sink_gating.py: the EnrichmentAnnotation.SCAN branch in the Slack
  and Mail sinks - scan blocks must be converted to report files before block
  conversion, uploaded/attached with correct filename and valid contents
- test_svg_conversion.py: convert_svg_to_png / add_pngs_for_all_svgs and the
  MS Teams SVG->JPEG data-URL path, including a chart built through the real
  pipeline with custom CSS injection (previously untested)
- test_ai_integration.py: strengthen PNG assertions with full image decoding,
  pin the pass-through-unchanged behavior for unconvertible graph events, and
  cover all ChartValuesFormat axis formats plus unknown-format fallback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTG88e6gtCnRrrf8Pgyhdo
Removes the two LGPL-3.0 dependencies flagged in the license review, keeping
behavior identical (validated by the pinning tests added in the previous
commit - all 24 pass unchanged except the PDF-stream decoding in the test
helper, plus 83 adjacent tests):

- fpdf2 -> reportlab (BSD-3-Clause): scanReportBlock_to_fileblock() rewritten
  with reportlab platypus; same landscape-A4 layout, header with grade/score,
  accent config section, per-kind tables with markdown bold and repeated
  header rows. The import is now lazy, so the sink layer no longer requires
  the PDF toolchain at import time.
- CairoSVG -> resvg (MPL-2.0, via MIT-licensed resvg-py bindings):
  convert_svg_to_png() now uses resvg_py; rendering fidelity verified
  side-by-side on a real robusta-styled pygal chart (custom CSS included),
  both rasterize identically at 1280x500. resvg-py ships manylinux wheels for
  x86_64 and aarch64, matching the multi-arch image build. The MS Teams sink
  now reuses the shared helper instead of importing cairosvg directly.
- Dockerfile: libcairo2 is no longer needed; add fonts-dejavu-core explicitly
  since it previously arrived only as a transitive dependency of libcairo2
  and chart text rendering still needs a system font.
- playbooks/pyproject.toml: drop the CairoSVG dependency (nothing in the
  playbooks package imports it; the runner venv provides the rasterizer).
- pyproject.toml: resvg-py added as a regular dependency, CairoSVG removed
  from the 'all' extra, pillow pin comment updated (it is a direct dependency
  of the MS Teams image conversion, no longer transitive via fpdf2/CairoSVG).
- ATTRIBUTION.md: swap CairoSVG for resvg and ReportLab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTG88e6gtCnRrrf8Pgyhdo
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Docker image ready for 4e82af1 (built in 2m 33s)

⚠️ Warning: does not support ARM (ARM images are built on release only - not on every PR)

Use this tag to pull the image for testing.

📋 Copy commands

⚠️ Temporary images are deleted after 30 days. Copy to a permanent registry before using them:

gcloud auth configure-docker us-central1-docker.pkg.dev
docker pull us-central1-docker.pkg.dev/robusta-development/temporary-builds/robusta-runner:4e82af1
docker tag us-central1-docker.pkg.dev/robusta-development/temporary-builds/robusta-runner:4e82af1 me-west1-docker.pkg.dev/robusta-development/development/robusta-runner-dev:4e82af1
docker push me-west1-docker.pkg.dev/robusta-development/development/robusta-runner-dev:4e82af1

Patch Helm values in one line:

helm upgrade --install robusta robusta/robusta \
  --reuse-values \
  --set runner.image=me-west1-docker.pkg.dev/robusta-development/development/robusta-runner-dev:4e82af1

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e68be27d-98bf-4af9-ba4d-888363286c82

📥 Commits

Reviewing files that changed from the base of the PR and between d808b07 and 68c58e1.

⛔ Files ignored due to path filters (1)
  • poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (1)
  • pyproject.toml
🚧 Files skipped from review as they are similar to previous changes (1)
  • pyproject.toml

Walkthrough

The change replaces CairoSVG with resvg-py for SVG-to-PNG conversion and fpdf2 with ReportLab for scan-report PDFs. It updates runtime dependencies, Microsoft Teams image handling, graph rendering tests, PDF rendering tests, and Slack and Mail sink tests.

Changes

Rendering backend replacement

Layer / File(s) Summary
Rendering dependency declarations
ATTRIBUTION.md, Dockerfile, pyproject.toml
Dependency and runtime package declarations replace CairoSVG and fpdf2 with resvg-py and ReportLab.
SVG conversion pipeline
src/robusta/core/reporting/utils.py, src/robusta/integrations/msteams/..., tests/test_svg_conversion.py, tests/test_ai_integration.py
SVG conversion uses resvg-py. Microsoft Teams reuses the shared converter before JPEG encoding. Tests validate PNG output, conversion failures, graph rendering, and format fallbacks.
Scan-report PDF rendering
src/robusta/core/sinks/transformer.py, tests/test_scan_report_rendering.py, tests/test_scan_sink_gating.py
Scan reports use ReportLab to create styled landscape PDF attachments. Tests validate PDF content and Slack and Mail delivery.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: avi-robusta

Sequence Diagram(s)

sequenceDiagram
  participant GraphTool
  participant convert_svg_to_png
  participant resvg_py
  participant MicrosoftTeams
  participant PNGToJPEGEncoder
  GraphTool->>convert_svg_to_png: SVG bytes
  convert_svg_to_png->>resvg_py: UTF-8 SVG
  resvg_py-->>convert_svg_to_png: PNG bytes
  MicrosoftTeams->>convert_svg_to_png: SVG image
  convert_svg_to_png-->>MicrosoftTeams: PNG bytes
  MicrosoftTeams->>PNGToJPEGEncoder: PNG bytes
  PNGToJPEGEncoder-->>MicrosoftTeams: JPEG data URL
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes both primary changes: replacing CairoSVG with resvg-py and migrating PDF generation to reportlab.
Description check ✅ Passed The description directly explains the dependency replacements, implementation changes, integrations, and tests included in the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/lgpl-distribution-obligations-40z68c

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (4)
src/robusta/core/sinks/transformer.py (1)

298-299: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the column-width computation out of the loop.

scan.table_widths does not change per section. Computing width_total and column_widths once before the loop removes repeated work and makes a zero-sum guard easier to add. If sum(scan.table_widths) is ever 0, the current code raises ZeroDivisionError.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/robusta/core/sinks/transformer.py` around lines 298 - 299, Move the
width_total and column_widths calculations out of the section loop in the
surrounding transformer flow, computing them once from scan.table_widths before
iteration. Add a zero-sum guard for width_total so zero-valued widths do not
cause division by zero, while preserving the existing column-width behavior for
positive totals.
tests/test_scan_report_rendering.py (2)

180-181: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The grade assertions are too weak to fail on a regression.

assert "B" in text and assert "C" in text match any capital B or C anywhere in the report. "DaemonSet" alone satisfies neither, but "Deployment"-adjacent content, container names, and config text make these checks near-unconditional. A wrong grade would still pass.

Assert the grade next to the score, for example assert "B85" in text.replace(" ", ""), or assert on the concatenated header text.

Also applies to: 203-204

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_scan_report_rendering.py` around lines 180 - 181, Strengthen the
grade assertions in the scan report rendering tests by verifying each grade is
adjacent to its expected score, such as checking the normalized header text for
“B85” and the corresponding grade-score combination at the other referenced
assertion. Replace broad standalone letter checks while preserving the existing
score validations.

30-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The extractor handles only Tj, but the docstring claims Tj/TJ.

Line 77 matches Tj operators only. ReportLab can emit TJ arrays for kerned or word-spaced runs. If that happens, text silently disappears and the assertions fail with a confusing message, or worse, pass for the wrong reason.

Either extend the regex to TJ arrays or correct the docstring to state the Tj-only limitation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_scan_report_rendering.py` around lines 30 - 79, Update
extract_pdf_text and its docstring consistently: either implement extraction for
TJ array text-showing operators in addition to Tj, or revise the docstring to
explicitly document that only Tj operators are supported. Prefer extending the
extractor so ReportLab-generated kerned or word-spaced text is retained, while
preserving existing unescaping and decoding behavior.
tests/test_scan_sink_gating.py (1)

166-176: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

FileMock.instances is shared state that leaks between tests.

instances is a class attribute and no test resets it. The assertion len(FileMock.instances) == 1 on Line 194 passes only because one test uses FileMock. A second test that patches NamedTemporaryFile with this class makes the count assertion fail, and the failure looks unrelated to the new test.

Define the list inside the fixture or clear it before use.

♻️ Proposed fix
     class FileMock(BytesIO):
         instances = []
 
         def __init__(self, *args, **kwargs):
             super().__init__(*args, **kwargs)
             self.name = f"tmpfile-{len(self.instances)}"
             FileMock.instances.append(self)
 
         def close(self):
             self._final_contents = self.getvalue()
             return super().close()
 
+    FileMock.instances = []
+
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_scan_sink_gating.py` around lines 166 - 176, Update the FileMock
test helper so its instances collection is reset for each test, preferably by
defining or clearing it in the relevant fixture before use. Preserve
FileMock.__init__ registration and the len(FileMock.instances) assertions while
preventing state from leaking across tests.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pyproject.toml`:
- Around line 39-40: Update the license description in the comment near the
SVG-to-PNG rasterizer dependency, replacing “MPL-2.0” with “Apache-2.0/MIT”
while keeping the existing resvg-py MIT licensing text unchanged.

In `@src/robusta/core/sinks/transformer.py`:
- Around line 275-277: Update the score handling in scanReportBlock_to_fileblock
to parse scan.score defensively, catching invalid or empty values such as
decimals, "N/A", or blank strings; only append the score badge and header width
when parsing succeeds and the numeric score is non-negative, otherwise skip the
badge without aborting the report.
- Around line 294-311: Update the scan table construction in
scan_row_content_format to enable ReportLab in-row splitting by setting the
table’s splitInRow option to 1. Preserve the existing rows, column widths,
repeated header row, and table styling.

---

Nitpick comments:
In `@src/robusta/core/sinks/transformer.py`:
- Around line 298-299: Move the width_total and column_widths calculations out
of the section loop in the surrounding transformer flow, computing them once
from scan.table_widths before iteration. Add a zero-sum guard for width_total so
zero-valued widths do not cause division by zero, while preserving the existing
column-width behavior for positive totals.

In `@tests/test_scan_report_rendering.py`:
- Around line 180-181: Strengthen the grade assertions in the scan report
rendering tests by verifying each grade is adjacent to its expected score, such
as checking the normalized header text for “B85” and the corresponding
grade-score combination at the other referenced assertion. Replace broad
standalone letter checks while preserving the existing score validations.
- Around line 30-79: Update extract_pdf_text and its docstring consistently:
either implement extraction for TJ array text-showing operators in addition to
Tj, or revise the docstring to explicitly document that only Tj operators are
supported. Prefer extending the extractor so ReportLab-generated kerned or
word-spaced text is retained, while preserving existing unescaping and decoding
behavior.

In `@tests/test_scan_sink_gating.py`:
- Around line 166-176: Update the FileMock test helper so its instances
collection is reset for each test, preferably by defining or clearing it in the
relevant fixture before use. Preserve FileMock.__init__ registration and the
len(FileMock.instances) assertions while preventing state from leaking across
tests.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d69b3620-7f1c-4e27-8bb5-f92753cab24e

📥 Commits

Reviewing files that changed from the base of the PR and between e022175 and 44138d3.

⛔ Files ignored due to path filters (1)
  • poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • ATTRIBUTION.md
  • Dockerfile
  • playbooks/pyproject.toml
  • pyproject.toml
  • src/robusta/core/reporting/utils.py
  • src/robusta/core/sinks/transformer.py
  • src/robusta/integrations/msteams/msteams_adaptive_card_files_image.py
  • tests/test_ai_integration.py
  • tests/test_scan_report_rendering.py
  • tests/test_scan_sink_gating.py
  • tests/test_svg_conversion.py
💤 Files with no reviewable changes (1)
  • playbooks/pyproject.toml

Comment thread pyproject.toml Outdated
Comment thread src/robusta/core/sinks/transformer.py Outdated
Comment thread src/robusta/core/sinks/transformer.py
claude and others added 3 commits August 11, 2026 09:22
The previous relock used Poetry 2.3.3, which rewrote the whole file in the
2.1 lock format. Regenerate with Poetry 1.8.5 (the version that produced the
original lock) via 'poetry lock --no-update' so the format stays 2.0 and the
diff against master is limited to the intended dependency swap: fpdf2 and
CairoSVG (plus their orphaned transitives cairocffi, cssselect2, tinycss2,
webencodings, defusedxml) out; reportlab and resvg-py in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTG88e6gtCnRrrf8Pgyhdo
…t tall table rows

- pyproject.toml: resvg-py 0.3.4 bundles resvg 0.48.0, which is licensed
  Apache-2.0 OR MIT (the MPL-2.0 note was resvg's pre-linebender license) -
  verified against the v0.48.0 Cargo.toml and license files.
- transformer.py: a non-integer scan score (e.g. "85.5", "N/A", "") no longer
  raises out of scanReportBlock_to_fileblock and kills the notification; the
  score badge is skipped with a warning instead. Matches the old fpdf2-era
  guard semantics for valid integer scores. Note scan.grade parses the score
  too, so the whole badge block sits inside the guard.
- transformer.py: enable splitByRow/splitInRow on scan tables so a single row
  taller than the page (e.g. a Popeye resource with hundreds of issues in one
  cell) splits across pages instead of raising LayoutError.
- tests: pin both behaviors (parametrized bad-score cases and a 200-issue
  tall-row case).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTG88e6gtCnRrrf8Pgyhdo

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_scan_report_rendering.py`:
- Around line 232-241: Strengthen
test_non_integer_score_skips_badge_without_breaking_report by asserting the
score-specific header text is absent from the extracted PDF text for every
bad_score, including empty and None values, while retaining the existing
report-rendering assertions.
- Around line 244-258: Update test_row_taller_than_page_splits_across_pages to
assert the rendered PDF has more than one page and verify every generated issue
number 0 through 199 appears in the extracted text, preserving the full expected
message text rather than checking only the endpoints.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b0792f1b-e30d-4dba-a51a-d4d1e8ea4a9e

📥 Commits

Reviewing files that changed from the base of the PR and between 44138d3 and 2a0df9e.

⛔ Files ignored due to path filters (1)
  • poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • pyproject.toml
  • src/robusta/core/sinks/transformer.py
  • tests/test_scan_report_rendering.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • pyproject.toml
  • src/robusta/core/sinks/transformer.py

Comment thread tests/test_scan_report_rendering.py
Comment thread tests/test_scan_report_rendering.py Outdated
…ination

- test_non_integer_score_skips_badge_without_breaking_report now asserts the
  invalid score text is absent from the rendered PDF, not only that the
  report still renders.
- test_row_taller_than_page_splits_across_pages now asserts the PDF spans
  multiple pages and that every one of the 200 issue messages survives the
  in-row split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTG88e6gtCnRrrf8Pgyhdo
Comment thread pyproject.toml Outdated
@Avi-Robusta
Avi-Robusta self-requested a review August 11, 2026 10:57
Supply-chain hardening: the resvg-py binding package has a small
maintainer base, so pin the exact version instead of a caret range.
Lock file content-hash refreshed with Poetry 1.8.5 (no dependency
versions changed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTG88e6gtCnRrrf8Pgyhdo
@moshemorad
moshemorad merged commit 9243468 into master Aug 11, 2026
5 checks passed
@moshemorad
moshemorad deleted the claude/lgpl-distribution-obligations-40z68c branch August 11, 2026 12:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants