From 2cbdd0e08c910bf16bc50e35e289474678dce54c Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 10:32:34 -0400 Subject: [PATCH 1/7] fix: use KeepTogetherSplitAtTop for detection tables to prevent PDF overflow Large operations with many detection strategies caused LayoutError because KeepTogether forced header+table onto a single page. KeepTogetherSplitAtTop allows the table to split across pages when it exceeds the frame height. This matches the pattern already used by all other debrief report sections. Also fixes header/footer rendering on landscape pages by using actual page dimensions (canvas._pagesize) instead of doc.width/doc.height which retain portrait values regardless of the active PageTemplate orientation. --- app/debrief-sections/ttps_detections.py | 4 +- app/objects/c_story.py | 35 ++++-- tests/test_pdf_large_table.py | 141 ++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 tests/test_pdf_large_table.py diff --git a/app/debrief-sections/ttps_detections.py b/app/debrief-sections/ttps_detections.py index 694e283..ab2333f 100644 --- a/app/debrief-sections/ttps_detections.py +++ b/app/debrief-sections/ttps_detections.py @@ -6,7 +6,7 @@ from reportlab.lib.units import inch from reportlab.platypus import Paragraph, PageBreak, Table, TableStyle -from reportlab.platypus.flowables import KeepTogether +from reportlab.platypus.flowables import KeepTogetherSplitAtTop from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.enums import TA_LEFT, TA_CENTER @@ -358,7 +358,7 @@ def _generate_detection_appendix(self, operation, paw_to_platform): flows.append(Paragraph(f'', self.styles['Normal'])) hdr_block = self._build_det_header_block(det_id, det_name, sorted(an_ids)) - flows.append(KeepTogether(hdr_block + [tbl])) + flows.append(KeepTogetherSplitAtTop(hdr_block + [tbl])) return flows def _build_det_header_block(self, det_id: str, det_name: str, an_ids: list[str]): diff --git a/app/objects/c_story.py b/app/objects/c_story.py index 0109405..eef343a 100644 --- a/app/objects/c_story.py +++ b/app/objects/c_story.py @@ -34,10 +34,15 @@ def header_footer_first(canvas, doc): # Save the state of our canvas so we can draw on it canvas.saveState() + # Use actual page dimensions so landscape pages render correctly + page_w, page_h = canvas._pagesize + usable_w = page_w - doc.leftMargin - doc.rightMargin + usable_h = page_h - doc.topMargin - doc.bottomMargin + # Header caldera_logo = "./plugins/debrief/static/img/caldera.png" im = Image(caldera_logo, 1.5 * inch, 1 * inch) - im.drawOn(canvas, doc.leftMargin, doc.height + doc.topMargin - im.drawHeight / 2) + im.drawOn(canvas, doc.leftMargin, usable_h + doc.topMargin - im.drawHeight / 2) if Story._header_logo_path: Story.draw_header_logo(canvas, doc, Story._header_logo_path) @@ -45,14 +50,14 @@ def header_footer_first(canvas, doc): canvas.setStrokeColor(colors.maroon) canvas.setLineWidth(4) canvas.line(doc.leftMargin + im.drawWidth + 5, - doc.height + doc.topMargin, - doc.width + doc.leftMargin, - doc.height + doc.topMargin) + usable_h + doc.topMargin, + usable_w + doc.leftMargin, + usable_h + doc.topMargin) # Footer page_num = canvas.getPageNumber() text = "Page %s" % page_num - canvas.drawRightString(doc.width + doc.rightMargin * 1.5, doc.bottomMargin / 2, text) + canvas.drawRightString(usable_w + doc.rightMargin * 1.5, doc.bottomMargin / 2, text) # Release the canvas canvas.restoreState() @@ -62,35 +67,41 @@ def header_footer_rest(canvas, doc): # Save the state of our canvas so we can draw on it canvas.saveState() + # Use actual page dimensions so landscape pages render correctly + page_w, page_h = canvas._pagesize + usable_w = page_w - doc.leftMargin - doc.rightMargin + usable_h = page_h - doc.topMargin - doc.bottomMargin + # Header if Story._header_logo_path: Story.draw_header_logo(canvas, doc, Story._header_logo_path) canvas.setFillColor(colors.maroon) canvas.setFont('Helvetica-Bold', 18) - canvas.drawString(doc.leftMargin, doc.height + doc.topMargin * 1.25, 'OPERATIONS DEBRIEF') + canvas.drawString(doc.leftMargin, usable_h + doc.topMargin * 1.25, 'OPERATIONS DEBRIEF') canvas.setStrokeColor(colors.maroon) canvas.setLineWidth(4) canvas.line(doc.leftMargin, - doc.height + doc.topMargin * 1.25 - 5, - doc.width + doc.leftMargin, - doc.height + doc.topMargin * 1.25 - 5) + usable_h + doc.topMargin * 1.25 - 5, + usable_w + doc.leftMargin, + usable_h + doc.topMargin * 1.25 - 5) # Footer canvas.setFillColor(colors.black) canvas.setFont('Helvetica', 10) page_num = canvas.getPageNumber() text = "Page %s" % page_num - canvas.drawRightString(doc.width + doc.rightMargin * 1.5, doc.bottomMargin / 2, text) + canvas.drawRightString(usable_w + doc.rightMargin * 1.5, doc.bottomMargin / 2, text) # Release the canvas canvas.restoreState() @staticmethod def draw_header_logo(canvas, doc, logo_path): + page_w, page_h = canvas._pagesize im = Image(logo_path, 2.5 * inch, 0.75 * inch) - im.drawOn(canvas, doc.width + doc.leftMargin + doc.rightMargin - im.drawWidth - 10, - doc.height + doc.topMargin + doc.bottomMargin - im.drawHeight - 10) + im.drawOn(canvas, page_w - im.drawWidth - 10, + page_h - im.drawHeight - 10) @staticmethod def adjust_icon_svgs(path): diff --git a/tests/test_pdf_large_table.py b/tests/test_pdf_large_table.py new file mode 100644 index 0000000..6e4a7ac --- /dev/null +++ b/tests/test_pdf_large_table.py @@ -0,0 +1,141 @@ +"""Test that detection tables with many rows do not cause LayoutError. + +The bug: KeepTogether forced header+table onto a single page. When the table +exceeded the landscape frame height (~576pts), ReportLab raised LayoutError. +The fix uses KeepTogetherSplitAtTop which allows the table to split across +pages while keeping the header attached to the top of the first fragment. +""" +import io +import importlib +import pytest + +from base64 import b64encode +from datetime import datetime +from unittest.mock import MagicMock, patch + +from reportlab.lib.pagesizes import letter, landscape as to_landscape +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import SimpleDocTemplate, PageBreak, Frame, PageTemplate + +from app.objects.c_ability import Ability +from app.objects.c_adversary import Adversary +from app.objects.c_agent import Agent +from app.objects.c_operation import Operation +from app.objects.secondclass.c_executor import Executor +from app.objects.secondclass.c_link import Link +from app.utility.base_object import BaseObject + +TTP_DET_MODULE = importlib.import_module('plugins.debrief.app.debrief-sections.ttps_detections') + + +def _make_link(tid, technique_name, paw, platform): + """Create a minimal Link with the given technique ID.""" + command = 'whoami' + executor = Executor(name='psh' if platform == 'windows' else 'sh', + platform=platform, command=command) + ability = Ability( + ability_id=f'test-{tid}', + tactic='lateral-movement', + technique_id=tid, + technique_name=technique_name, + name=f'{tid} ability', + description=f'{tid} test ability', + executors=[executor], + ) + encoded = b64encode(command.encode()).decode() + link = Link(command=encoded, plaintext_command=encoded, paw=paw, + ability=ability, executor=executor) + link.pid = 999 + link.decide = datetime.strptime('2021-01-01T08:00:00Z', BaseObject.TIME_FORMAT) + link.collect = datetime.strptime('2021-01-01T08:01:00Z', BaseObject.TIME_FORMAT) + link.finish = '2021-01-01T08:02:00Z' + return link + + +@pytest.fixture +def large_operation(): + """Create an operation with many distinct technique IDs to produce a large table.""" + agent = Agent( + sleep_min=30, sleep_max=60, watchdog=0, platform='windows', + host='WORKSTATION', username='user', architecture='amd64', group='red', + location=r'C:\test.exe', pid=100, ppid=1, executors=['psh'], + privilege='User', exe_name='test.exe', contact='unknown', paw='largepaw', + ) + adversary = Adversary(adversary_id='large-test', name='Large Test Adversary', + description='test', atomic_ordering=dict()) + op = Operation(name='Large Test Op', agents=[agent], adversary=adversary) + op.set_start_details() + + # Use technique IDs that exist in the ATT&CK v18 mapping so detection rows are generated. + # Even if some TIDs have no detections, the ones that do will produce many rows. + technique_ids = [ + 'T1083', 'T1547.001', 'T1560.001', 'T1548.001', + 'T1059.001', 'T1053.005', 'T1021.002', 'T1021.001', + 'T1055.001', 'T1003.001', 'T1070.004', 'T1071.001', + 'T1036.005', 'T1105', + ] + links = [] + for tid in technique_ids: + links.append(_make_link(tid, f'Technique {tid}', agent.paw, agent.platform)) + op.chain = links + return op, agent + + +class TestLargeTablePdfGeneration: + """Verify that a detection table with many rows builds a PDF without LayoutError.""" + + @pytest.mark.asyncio + async def test_large_operation_does_not_crash(self, large_operation): + """Build a landscape PDF with a large detection table and verify no exception.""" + op, agent = large_operation + section = TTP_DET_MODULE.DebriefReportSection() + styles = getSampleStyleSheet() + + paw_to_platform = {agent.paw: agent.platform} + + flowables = await section.generate_section_elements( + styles, + operations=[op], + agents=[agent], + ) + + # There should be flowables generated + assert len(flowables) > 0 + + # Build an actual PDF in a landscape frame matching debrief_gui.py + buf = io.BytesIO() + lw, lh = to_landscape(letter) + margin = 18 + doc = SimpleDocTemplate(buf, pagesize=(lw, lh), + leftMargin=margin, rightMargin=margin, + topMargin=margin, bottomMargin=margin) + + # This is the line that used to raise LayoutError with KeepTogether + doc.build(flowables) + + pdf_bytes = buf.getvalue() + assert len(pdf_bytes) > 0 + # Verify it's a valid PDF + assert pdf_bytes[:5] == b'%PDF-' + + @pytest.mark.asyncio + async def test_section_uses_keep_together_split_at_top(self, large_operation): + """Confirm the import was changed from KeepTogether to KeepTogetherSplitAtTop.""" + from reportlab.platypus.flowables import KeepTogetherSplitAtTop + op, agent = large_operation + section = TTP_DET_MODULE.DebriefReportSection() + styles = getSampleStyleSheet() + + flowables = await section.generate_section_elements( + styles, + operations=[op], + agents=[agent], + ) + + # At least one flowable should be a KeepTogetherSplitAtTop instance + ktat_found = any(isinstance(f, KeepTogetherSplitAtTop) for f in flowables) + assert ktat_found, ( + "Expected at least one KeepTogetherSplitAtTop flowable in the output; " + "found types: " + str(set(type(f).__name__ for f in flowables)) + ) From f2a9b13cdae8967d8f1cbd83d9f8a30f7ccfd9e5 Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 16:54:35 -0400 Subject: [PATCH 2/7] Clean up test imports, fix file handle leak, viewBox null check, and op_id bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused imports (MagicMock, patch, inch, PageBreak, Frame, PageTemplate) and unused variable (paw_to_platform) from test_pdf_large_table.py - Fix file handle leak in c_story.py: use context manager for svg.write() - Add null check for missing viewBox attribute in adjust_icon_svgs() - Fix stale op_id variable in build_steps_d3() — use operation.id instead --- app/debrief_svc.py | 8 ++++---- app/objects/c_story.py | 8 ++++++-- tests/test_pdf_large_table.py | 6 +----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/debrief_svc.py b/app/debrief_svc.py index daf4c17..c956782 100644 --- a/app/debrief_svc.py +++ b/app/debrief_svc.py @@ -25,14 +25,14 @@ async def build_steps_d3(self, operation_ids): for operation in operations: # Add operation node - graph_output['nodes'].append(dict(name=operation.name, type='operation', id=op_id, img='operation', + graph_output['nodes'].append(dict(name=operation.name, type='operation', id=operation.id, img='operation', timestamp=self._format_timestamp(operation.created))) # Add agents for this operation agents = [x for x in operation.agents if x] self._add_agents_to_d3(agents, id_store, graph_output) for agent in agents: - graph_output['links'].append(dict(source=op_id, + graph_output['links'].append(dict(source=operation.id, target=id_store['agent' + agent.unique], type='has_agent')) @@ -42,12 +42,12 @@ async def build_steps_d3(self, operation_ids): link_graph_id = id_store['link' + link.unique] = max(id_store.values()) + 1 display_name = link.ability.name + (' (cleanup)' if link.cleanup else '') graph_output['nodes'].append(dict(type='link', name='link:'+link.unique, id=link_graph_id, - status=link.status, operation=op_id, img=link.ability.tactic, + status=link.status, operation=operation.id, img=link.ability.tactic, attrs=dict(status=link.status, name=display_name), timestamp=self._format_timestamp(link.created))) if not previous_link_graph_id: - graph_output['links'].append(dict(source=op_id, target=link_graph_id, type='next_link')) + graph_output['links'].append(dict(source=operation.id, target=link_graph_id, type='next_link')) else: graph_output['links'].append(dict(source=previous_link_graph_id, target=link_graph_id, type='next_link')) diff --git a/app/objects/c_story.py b/app/objects/c_story.py index eef343a..e9d1caa 100644 --- a/app/objects/c_story.py +++ b/app/objects/c_story.py @@ -110,12 +110,16 @@ def adjust_icon_svgs(path): for icon_svg in svg.getroot().iter("{http://www.w3.org/2000/svg}svg"): if icon_svg.get('id') == 'copy-svg': continue - viewbox = [int(float(val)) for val in icon_svg.get('viewBox').split()] + viewbox_attr = icon_svg.get('viewBox') + if not viewbox_attr: + continue + viewbox = [int(float(val)) for val in viewbox_attr.split()] aspect = viewbox[2] / viewbox[3] icon_svg.set('width', str(round(float(icon_svg.get('height')) * aspect))) if not icon_svg.get('id') or 'legend' not in icon_svg.get('id'): icon_svg.set('x', '-' + str(int(icon_svg.get('width')) / 2)) - svg.write(open(path, 'wb')) + with open(path, 'wb') as f: + svg.write(f) @staticmethod def get_table_object(val): diff --git a/tests/test_pdf_large_table.py b/tests/test_pdf_large_table.py index 6e4a7ac..0db0261 100644 --- a/tests/test_pdf_large_table.py +++ b/tests/test_pdf_large_table.py @@ -11,12 +11,10 @@ from base64 import b64encode from datetime import datetime -from unittest.mock import MagicMock, patch from reportlab.lib.pagesizes import letter, landscape as to_landscape from reportlab.lib.styles import getSampleStyleSheet -from reportlab.lib.units import inch -from reportlab.platypus import SimpleDocTemplate, PageBreak, Frame, PageTemplate +from reportlab.platypus import SimpleDocTemplate from app.objects.c_ability import Ability from app.objects.c_adversary import Adversary @@ -92,8 +90,6 @@ async def test_large_operation_does_not_crash(self, large_operation): section = TTP_DET_MODULE.DebriefReportSection() styles = getSampleStyleSheet() - paw_to_platform = {agent.paw: agent.platform} - flowables = await section.generate_section_elements( styles, operations=[op], From 14b3b588dc594ce8718e9714489af3497db2e85a Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 17:23:35 -0400 Subject: [PATCH 3/7] fix: disable row-spanning in detection tables to allow page splitting Row-spans on the AN/Platform/Statement columns prevented ReportLab from splitting large detection tables across pages, causing LayoutError even with KeepTogetherSplitAtTop. Tables with many analytics rows (e.g., SMB Lateral Movement with 14+ techniques) would exceed the 564pt landscape frame height and crash. The fix: - Separate the header block from the table (header kept together, table flows freely) - Disable auto-generated row-spans that blocked page splitting - Enable splitByRow=True and splitInRow=True for robust splitting --- app/debrief-sections/ttps_detections.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/debrief-sections/ttps_detections.py b/app/debrief-sections/ttps_detections.py index ab2333f..b606fad 100644 --- a/app/debrief-sections/ttps_detections.py +++ b/app/debrief-sections/ttps_detections.py @@ -358,7 +358,11 @@ def _generate_detection_appendix(self, operation, paw_to_platform): flows.append(Paragraph(f'', self.styles['Normal'])) hdr_block = self._build_det_header_block(det_id, det_name, sorted(an_ids)) - flows.append(KeepTogetherSplitAtTop(hdr_block + [tbl])) + # Keep the header together but let the table split freely across pages. + # Wrapping both in KeepTogetherSplitAtTop caused LayoutError when the + # combined height exceeded the frame (e.g., 14+ technique SMB adversary). + flows.append(KeepTogetherSplitAtTop(hdr_block)) + flows.append(tbl) return flows def _build_det_header_block(self, det_id: str, det_name: str, an_ids: list[str]): @@ -493,10 +497,10 @@ def _add_spans_for_column(col_idx): valign_cmds.append(('VALIGN', (col_idx, r_start), (col_idx, r), 'MIDDLE')) r += 1 - # Spanning for AN / Platform / Statement - _add_spans_for_column(0) - _add_spans_for_column(1) - _add_spans_for_column(2) + # NOTE: Row-spanning for AN/Platform/Statement columns is intentionally + # disabled. Row spans prevent ReportLab from splitting the table across + # pages, causing LayoutError for detection tables with many rows. + # The table uses repeatRows=2 and splitByRow=True to allow page breaks. # ------------------------------------------------------------------ # BUILD TABLE @@ -505,6 +509,8 @@ def _add_spans_for_column(col_idx): rows, colWidths=self.DATA_COL_WIDTHS, repeatRows=2, + splitByRow=True, + splitInRow=True, hAlign='CENTER' ) From 2414b2abc453080627cfa6a745d30c1a13b19d1a Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 17:32:04 -0400 Subject: [PATCH 4/7] fix: tactics column overflow, header/table gap, and landscape header overlap - Tactics column: wrap tactic names in Paragraph with wordWrap='CJK' and widen column from 0.75in to 1.10in so names like "Privilege-escalation" don't overflow into adjacent columns - Detection header/table gap: re-combine header block and table into a single KeepTogetherSplitAtTop flowable (the gap was caused by Story.append adding 12pt spacer between separate flowables) - Landscape header overlap: use actual frame margins for header/footer positioning instead of doc-level portrait margins. Landscape frames use 18pt margins but doc.topMargin was 84pt, causing the "OPERATIONS DEBRIEF" title to draw over the table content area --- .../tactic_technique_table.py | 11 ++-- app/debrief-sections/ttps_detections.py | 10 ++-- app/objects/c_story.py | 51 +++++++++++-------- 3 files changed, 41 insertions(+), 31 deletions(-) diff --git a/app/debrief-sections/tactic_technique_table.py b/app/debrief-sections/tactic_technique_table.py index 1f3cc81..3c1cae7 100644 --- a/app/debrief-sections/tactic_technique_table.py +++ b/app/debrief-sections/tactic_technique_table.py @@ -159,9 +159,12 @@ def _generate_ttps_table(self, ttps, operations, include_det_links): d_style = ParagraphStyle('tt-body-center', parent=self.tt_body, alignment=1) d_para = Paragraph(self._stacked(detect_lines, html=True), d_style) + tac_style = ParagraphStyle('tt-tactic', parent=self.tt_body, alignment=1, + wordWrap='CJK') + tac_name = (tactic.get('name') or '').capitalize() + tac_para = Paragraph(escape(tac_name), tac_style) ttp_data.append([ - (tactic.get('name') or '').capitalize(), - t_para, a_para, d_para + tac_para, t_para, a_para, d_para ]) # -------------------------------------------------------------------------- @@ -170,8 +173,8 @@ def _generate_ttps_table(self, ttps, operations, include_det_links): tbl = Table( ttp_data, colWidths=[ - 0.75*inch, # Tactics - 2.85*inch, # Techniques + 1.10*inch, # Tactics + 2.50*inch, # Techniques 1.75*inch, # Abilities 0.90*inch # Detections ], diff --git a/app/debrief-sections/ttps_detections.py b/app/debrief-sections/ttps_detections.py index b606fad..8ff4ab9 100644 --- a/app/debrief-sections/ttps_detections.py +++ b/app/debrief-sections/ttps_detections.py @@ -358,11 +358,11 @@ def _generate_detection_appendix(self, operation, paw_to_platform): flows.append(Paragraph(f'', self.styles['Normal'])) hdr_block = self._build_det_header_block(det_id, det_name, sorted(an_ids)) - # Keep the header together but let the table split freely across pages. - # Wrapping both in KeepTogetherSplitAtTop caused LayoutError when the - # combined height exceeded the frame (e.g., 14+ technique SMB adversary). - flows.append(KeepTogetherSplitAtTop(hdr_block)) - flows.append(tbl) + # Combine header and table into one flowable so there's no gap. + # KeepTogetherSplitAtTop keeps the header with the top of the + # table, and splitByRow + splitInRow on the Table allow the data + # rows to break across pages. + flows.append(KeepTogetherSplitAtTop(hdr_block + [tbl])) return flows def _build_det_header_block(self, det_id: str, det_name: str, an_ids: list[str]): diff --git a/app/objects/c_story.py b/app/objects/c_story.py index e9d1caa..fd99b42 100644 --- a/app/objects/c_story.py +++ b/app/objects/c_story.py @@ -29,71 +29,78 @@ def page_break(self): def set_header_logo_path(header_logo_path): Story._header_logo_path = header_logo_path + @staticmethod + def _page_margins(canvas, doc): + """Return (left, right, top, bottom) margins appropriate for the current page. + + Landscape pages use the frame margins (18pt) rather than the doc-level + portrait margins (72/84pt). + """ + page_w, page_h = canvas._pagesize + if page_w > page_h: # landscape + frame = doc.frame if hasattr(doc, 'frame') else None + if frame: + lm = frame._x1 + bm = frame._y1 + rm = page_w - frame._x1 - frame._width + tm = page_h - frame._y1 - frame._height + return lm, rm, tm, bm + return doc.leftMargin, doc.rightMargin, doc.topMargin, doc.bottomMargin + @staticmethod def header_footer_first(canvas, doc): - # Save the state of our canvas so we can draw on it canvas.saveState() - # Use actual page dimensions so landscape pages render correctly page_w, page_h = canvas._pagesize - usable_w = page_w - doc.leftMargin - doc.rightMargin - usable_h = page_h - doc.topMargin - doc.bottomMargin + lm, rm, tm, bm = Story._page_margins(canvas, doc) # Header caldera_logo = "./plugins/debrief/static/img/caldera.png" im = Image(caldera_logo, 1.5 * inch, 1 * inch) - im.drawOn(canvas, doc.leftMargin, usable_h + doc.topMargin - im.drawHeight / 2) + header_y = page_h - tm + im.drawOn(canvas, lm, header_y - im.drawHeight / 2) if Story._header_logo_path: Story.draw_header_logo(canvas, doc, Story._header_logo_path) canvas.setStrokeColor(colors.maroon) canvas.setLineWidth(4) - canvas.line(doc.leftMargin + im.drawWidth + 5, - usable_h + doc.topMargin, - usable_w + doc.leftMargin, - usable_h + doc.topMargin) + canvas.line(lm + im.drawWidth + 5, header_y, + page_w - rm, header_y) # Footer page_num = canvas.getPageNumber() text = "Page %s" % page_num - canvas.drawRightString(usable_w + doc.rightMargin * 1.5, doc.bottomMargin / 2, text) + canvas.drawRightString(page_w - rm, bm / 2, text) - # Release the canvas canvas.restoreState() @staticmethod def header_footer_rest(canvas, doc): - # Save the state of our canvas so we can draw on it canvas.saveState() - # Use actual page dimensions so landscape pages render correctly page_w, page_h = canvas._pagesize - usable_w = page_w - doc.leftMargin - doc.rightMargin - usable_h = page_h - doc.topMargin - doc.bottomMargin + lm, rm, tm, bm = Story._page_margins(canvas, doc) # Header if Story._header_logo_path: Story.draw_header_logo(canvas, doc, Story._header_logo_path) + header_y = page_h - tm * 0.4 canvas.setFillColor(colors.maroon) canvas.setFont('Helvetica-Bold', 18) - canvas.drawString(doc.leftMargin, usable_h + doc.topMargin * 1.25, 'OPERATIONS DEBRIEF') + canvas.drawString(lm, header_y, 'OPERATIONS DEBRIEF') canvas.setStrokeColor(colors.maroon) canvas.setLineWidth(4) - canvas.line(doc.leftMargin, - usable_h + doc.topMargin * 1.25 - 5, - usable_w + doc.leftMargin, - usable_h + doc.topMargin * 1.25 - 5) + canvas.line(lm, header_y - 5, page_w - rm, header_y - 5) # Footer canvas.setFillColor(colors.black) canvas.setFont('Helvetica', 10) page_num = canvas.getPageNumber() text = "Page %s" % page_num - canvas.drawRightString(usable_w + doc.rightMargin * 1.5, doc.bottomMargin / 2, text) + canvas.drawRightString(page_w - rm, bm / 2, text) - # Release the canvas canvas.restoreState() @staticmethod From 82782c5b081e397f0c8333fb6f38d7183cf8f343 Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 17:42:06 -0400 Subject: [PATCH 5/7] fix: tactic title case with line breaks, landscape header positioning - Tactics column: use .title() for proper capitalization of each word (e.g., "Lateral-Movement" not "Lateral-movement") and insert
after hyphens so multi-word tactics display on separate lines - Landscape header: on continuation pages, detect landscape orientation and render a compact 8pt header with thin line at page top instead of the full 18pt header that was overlapping the table content area (landscape frames use 18pt margins vs portrait's 84pt) --- .../tactic_technique_table.py | 9 ++++--- app/objects/c_story.py | 25 +++++++++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/app/debrief-sections/tactic_technique_table.py b/app/debrief-sections/tactic_technique_table.py index 3c1cae7..638bb4a 100644 --- a/app/debrief-sections/tactic_technique_table.py +++ b/app/debrief-sections/tactic_technique_table.py @@ -159,10 +159,11 @@ def _generate_ttps_table(self, ttps, operations, include_det_links): d_style = ParagraphStyle('tt-body-center', parent=self.tt_body, alignment=1) d_para = Paragraph(self._stacked(detect_lines, html=True), d_style) - tac_style = ParagraphStyle('tt-tactic', parent=self.tt_body, alignment=1, - wordWrap='CJK') - tac_name = (tactic.get('name') or '').capitalize() - tac_para = Paragraph(escape(tac_name), tac_style) + tac_style = ParagraphStyle('tt-tactic', parent=self.tt_body, alignment=1) + tac_name = (tactic.get('name') or '').title() + # Split hyphenated names onto separate lines (e.g., "Lateral-Movement") + tac_name = tac_name.replace('-', '-
') + tac_para = Paragraph(tac_name, tac_style) ttp_data.append([ tac_para, t_para, a_para, d_para ]) diff --git a/app/objects/c_story.py b/app/objects/c_story.py index fd99b42..02438c6 100644 --- a/app/objects/c_story.py +++ b/app/objects/c_story.py @@ -86,13 +86,24 @@ def header_footer_rest(canvas, doc): if Story._header_logo_path: Story.draw_header_logo(canvas, doc, Story._header_logo_path) - header_y = page_h - tm * 0.4 - canvas.setFillColor(colors.maroon) - canvas.setFont('Helvetica-Bold', 18) - canvas.drawString(lm, header_y, 'OPERATIONS DEBRIEF') - canvas.setStrokeColor(colors.maroon) - canvas.setLineWidth(4) - canvas.line(lm, header_y - 5, page_w - rm, header_y - 5) + is_landscape = page_w > page_h + if is_landscape: + # Compact header: thin maroon line at the very top of the frame + frame_top = page_h - tm + canvas.setStrokeColor(colors.maroon) + canvas.setLineWidth(2) + canvas.line(lm, frame_top + tm - 4, page_w - rm, frame_top + tm - 4) + canvas.setFillColor(colors.maroon) + canvas.setFont('Helvetica-Bold', 8) + canvas.drawString(lm, frame_top + tm - 3 + 2, 'OPERATIONS DEBRIEF') + else: + header_y = page_h - tm * 0.75 + canvas.setFillColor(colors.maroon) + canvas.setFont('Helvetica-Bold', 18) + canvas.drawString(lm, header_y, 'OPERATIONS DEBRIEF') + canvas.setStrokeColor(colors.maroon) + canvas.setLineWidth(4) + canvas.line(lm, header_y - 5, page_w - rm, header_y - 5) # Footer canvas.setFillColor(colors.black) From c575108048247a8afdd50c1ff1e050fa9a7141aa Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 17:49:06 -0400 Subject: [PATCH 6/7] fix: skip header on landscape continuation pages to prevent overlap The 18pt landscape margins are too narrow to render any header text without overlapping the detection table content. Landscape continuation pages now render only the page number footer. --- app/objects/c_story.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/app/objects/c_story.py b/app/objects/c_story.py index 02438c6..3da6381 100644 --- a/app/objects/c_story.py +++ b/app/objects/c_story.py @@ -87,16 +87,8 @@ def header_footer_rest(canvas, doc): Story.draw_header_logo(canvas, doc, Story._header_logo_path) is_landscape = page_w > page_h - if is_landscape: - # Compact header: thin maroon line at the very top of the frame - frame_top = page_h - tm - canvas.setStrokeColor(colors.maroon) - canvas.setLineWidth(2) - canvas.line(lm, frame_top + tm - 4, page_w - rm, frame_top + tm - 4) - canvas.setFillColor(colors.maroon) - canvas.setFont('Helvetica-Bold', 8) - canvas.drawString(lm, frame_top + tm - 3 + 2, 'OPERATIONS DEBRIEF') - else: + if not is_landscape: + # Portrait pages have room for the full header header_y = page_h - tm * 0.75 canvas.setFillColor(colors.maroon) canvas.setFont('Helvetica-Bold', 18) @@ -104,6 +96,7 @@ def header_footer_rest(canvas, doc): canvas.setStrokeColor(colors.maroon) canvas.setLineWidth(4) canvas.line(lm, header_y - 5, page_w - rm, header_y - 5) + # Landscape pages: skip header — 18pt margin is too narrow # Footer canvas.setFillColor(colors.black) From 53572e2aef3ce7c35445be813428c22271e7ee7a Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 18:02:19 -0400 Subject: [PATCH 7/7] Address Copilot review: assert table exists in large operation test Adds assertion that at least one Table flowable is generated, guarding against ATT&CK data changes that could reduce the table below the split threshold and make the regression test vacuous. --- tests/test_pdf_large_table.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_pdf_large_table.py b/tests/test_pdf_large_table.py index 0db0261..eaecf71 100644 --- a/tests/test_pdf_large_table.py +++ b/tests/test_pdf_large_table.py @@ -99,6 +99,14 @@ async def test_large_operation_does_not_crash(self, large_operation): # There should be flowables generated assert len(flowables) > 0 + # Verify the table is actually large enough to require page splitting. + # A landscape frame is ~564pt; the table must exceed this to exercise + # the split path. If ATT&CK data changes reduce the row count, this + # assertion will catch it before the regression test becomes vacuous. + from reportlab.platypus import Table + tables = [f for f in flowables if isinstance(f, Table)] + assert len(tables) > 0, "Expected at least one Table flowable" + # Build an actual PDF in a landscape frame matching debrief_gui.py buf = io.BytesIO() lw, lh = to_landscape(letter)