修复PDF归档长字段排版错乱

This commit is contained in:
汤学会 2026-06-12 18:02:06 +08:00
parent 64e7ac346d
commit 06c7b800fc
2 changed files with 146 additions and 13 deletions

View File

@ -59,6 +59,16 @@ ARCHIVE_MIN_ROW_HEIGHT = 24
ARCHIVE_CELL_HORIZONTAL_PADDING = 8
ARCHIVE_CELL_VERTICAL_PADDING = 8
ARCHIVE_CELL_FONT_SIZE = 8
ARCHIVE_METADATA_FONT_SIZE = 10
ARCHIVE_METADATA_LEFT_X = 42
ARCHIVE_METADATA_RIGHT_X = 340
ARCHIVE_METADATA_COLUMN_GAP = 10
ARCHIVE_METADATA_LEFT_WIDTH = ARCHIVE_METADATA_RIGHT_X - ARCHIVE_METADATA_LEFT_X - ARCHIVE_METADATA_COLUMN_GAP
ARCHIVE_METADATA_RIGHT_WIDTH = A4[0] - ARCHIVE_TABLE_RIGHT_MARGIN - ARCHIVE_METADATA_RIGHT_X
ARCHIVE_METADATA_TOP_Y_OFFSET = 92
ARCHIVE_METADATA_MIN_ROW_HEIGHT = 20
ARCHIVE_METADATA_ROW_PADDING = 7
ARCHIVE_METADATA_TABLE_GAP = 18
ARCHIVE_STATUS_LABELS = {
"ACTIVE": "启用",
@ -1024,6 +1034,59 @@ def _draw_cell(
text_y -= line_height
def _archive_metadata_rows(context: ArchiveContext) -> list[tuple[tuple[str, str], tuple[str, str]]]:
contact_text = f"{context.contact_name or ''} 电话: {context.contact_phone or ''}"
tax_text = f"{decimal_text((context.tax_rate or 0) * 100)}%"
return [
(("单据编号", context.document_no), ("单据日期", date_text(context.document_date))),
((context.partner_label, context.partner_name), (context.due_date_label, date_text(context.due_date))),
(("联系人", contact_text), ("状态", context.status)),
((context.address_label or "地址", context.address or ""), ("税率", tax_text)),
]
def _archive_metadata_line_groups(
context: ArchiveContext,
font_name: str,
size: int = ARCHIVE_METADATA_FONT_SIZE,
) -> list[tuple[list[str], list[str]]]:
groups: list[tuple[list[str], list[str]]] = []
for (left_label, left_value), (right_label, right_value) in _archive_metadata_rows(context):
left_text = f"{left_label}: {left_value or ''}"
right_text = f"{right_label}: {right_value or ''}"
groups.append(
(
_wrap_text_to_lines(left_text, font_name, size, ARCHIVE_METADATA_LEFT_WIDTH),
_wrap_text_to_lines(right_text, font_name, size, ARCHIVE_METADATA_RIGHT_WIDTH),
)
)
return groups
def _metadata_row_height(line_count: int, size: int = ARCHIVE_METADATA_FONT_SIZE) -> float:
return max(ARCHIVE_METADATA_MIN_ROW_HEIGHT, line_count * _cell_line_height(size) + ARCHIVE_METADATA_ROW_PADDING)
def _archive_table_y_for_context(context: ArchiveContext, font_name: str) -> float:
_, height = A4
y = height - ARCHIVE_METADATA_TOP_Y_OFFSET
for left_lines, right_lines in _archive_metadata_line_groups(context, font_name):
y -= _metadata_row_height(max(len(left_lines), len(right_lines), 1))
return min(height - ARCHIVE_TABLE_TOP_Y_OFFSET, y - ARCHIVE_METADATA_TABLE_GAP)
def _draw_metadata_lines(
pdf: canvas.Canvas,
x: float,
y: float,
lines: list[str],
line_height: float,
) -> None:
for line in lines:
pdf.drawString(x, y, line)
y -= line_height
def _archive_table_columns() -> list[tuple[str, int, str]]:
return [
("", 26, "center"),
@ -1045,7 +1108,7 @@ def _draw_archive_page_frame(
bold_font_name: str,
page_no: int,
total_pages: int,
) -> None:
) -> float:
width, height = A4
pdf.setFillColorRGB(0.99, 0.975, 0.92)
pdf.rect(0, 0, width, height, stroke=0, fill=1)
@ -1061,15 +1124,13 @@ def _draw_archive_page_frame(
pdf.drawRightString(width - 42, height - 62, "PDF归档")
pdf.drawRightString(width - 42, height - 80, f"{page_no}/{total_pages}")
pdf.setFont(font_name, 10)
pdf.drawString(42, height - 92, f"单据编号: {context.document_no}")
pdf.drawString(42, height - 112, f"{context.partner_label}: {context.partner_name}")
pdf.drawString(42, height - 132, f"联系人: {context.contact_name or ''} 电话: {context.contact_phone or ''}")
pdf.drawString(42, height - 152, f"{context.address_label or '地址'}: {context.address or ''}")
pdf.drawString(340, height - 92, f"单据日期: {date_text(context.document_date)}")
pdf.drawString(340, height - 112, f"{context.due_date_label}: {date_text(context.due_date)}")
pdf.drawString(340, height - 132, f"状态: {context.status}")
pdf.drawString(340, height - 152, f"税率: {decimal_text((context.tax_rate or 0) * 100)}%")
pdf.setFont(font_name, ARCHIVE_METADATA_FONT_SIZE)
line_height = _cell_line_height(ARCHIVE_METADATA_FONT_SIZE)
metadata_y = height - ARCHIVE_METADATA_TOP_Y_OFFSET
for left_lines, right_lines in _archive_metadata_line_groups(context, font_name):
_draw_metadata_lines(pdf, ARCHIVE_METADATA_LEFT_X, metadata_y, left_lines, line_height)
_draw_metadata_lines(pdf, ARCHIVE_METADATA_RIGHT_X, metadata_y, right_lines, line_height)
metadata_y -= _metadata_row_height(max(len(left_lines), len(right_lines), 1))
pdf.setFont(bold_font_name, 26)
pdf.setStrokeColorRGB(0.55, 0.05, 0.05)
@ -1079,6 +1140,7 @@ def _draw_archive_page_frame(
pdf.rotate(-12)
pdf.setStrokeColor(colors.black)
pdf.setFillColor(colors.black)
return min(height - ARCHIVE_TABLE_TOP_Y_OFFSET, metadata_y - ARCHIVE_METADATA_TABLE_GAP)
def _draw_archive_table_header(pdf: canvas.Canvas, y: float, bold_font_name: str) -> None:
@ -1180,14 +1242,13 @@ def render_pdf_archive(context: ArchiveContext, output_path: str | Path) -> None
output.parent.mkdir(parents=True, exist_ok=True)
font_name, bold_font_name = _register_archive_font()
pdf = canvas.Canvas(str(output), pagesize=A4)
height = A4[1]
table_y = height - ARCHIVE_TABLE_TOP_Y_OFFSET
table_y = _archive_table_y_for_context(context, font_name)
row_h = ARCHIVE_MIN_ROW_HEIGHT
max_body_height = table_y - row_h - ARCHIVE_TABLE_BOTTOM_Y
pages = _line_pages(context.lines, max_body_height, font_name)
for page_index, page_lines in enumerate(pages, start=1):
_draw_archive_page_frame(pdf, context, font_name, bold_font_name, page_index, len(pages))
table_y = _draw_archive_page_frame(pdf, context, font_name, bold_font_name, page_index, len(pages))
_draw_archive_table_header(pdf, table_y, bold_font_name)
y = _draw_archive_table_rows(
pdf,

View File

@ -43,6 +43,7 @@ from app.services.document_archives import ( # noqa: E402
get_latest_archive,
render_pdf_archive,
_archive_table_columns,
_draw_archive_page_frame,
_draw_cell,
_draw_archive_footer,
)
@ -732,6 +733,77 @@ class DocumentArchiveServiceTest(unittest.TestCase):
rendered_width = pdfmetrics.stringWidth(line, fake_pdf.font_name, fake_pdf.size)
self.assertLessEqual(rendered_width, 54)
def test_draw_archive_page_frame_wraps_long_metadata_inside_columns(self) -> None:
class FakePdf:
def __init__(self) -> None:
self.font_name = "Helvetica"
self.size = 10
self.drawn_texts: list[tuple[float, float, str, str, int]] = []
def setFillColorRGB(self, *args, **kwargs) -> None:
_ = args, kwargs
def rect(self, *args, **kwargs) -> None:
_ = args, kwargs
def setStrokeColor(self, *args, **kwargs) -> None:
_ = args, kwargs
def setStrokeColorRGB(self, *args, **kwargs) -> None:
_ = args, kwargs
def setFillColor(self, *args, **kwargs) -> None:
_ = args, kwargs
def setFont(self, font_name, size) -> None:
self.font_name = font_name
self.size = size
def drawRightString(self, x, y, text) -> None:
_ = x, y, text
def drawCentredString(self, x, y, text) -> None:
_ = x, y, text
def drawString(self, x, y, text) -> None:
self.drawn_texts.append((x, y, text, self.font_name, self.size))
def rotate(self, *args, **kwargs) -> None:
_ = args, kwargs
fake_pdf = FakePdf()
context = ArchiveContext(
document_type=DOCUMENT_TYPE_SALES_ORDER,
business_id=100,
document_no="生产领料-JH_不锈钢板-用于测试00101_0001-苹果电脑后盖-超长批次-客户特殊版本-返工追溯链路",
title="生产领料出库单归档",
partner_label="生产产品",
partner_name="苹果电脑后盖-测试",
document_date=date(2026, 6, 11),
due_date_label="生产台账",
due_date=2,
status="在生产",
tax_rate=0,
total_amount=636,
contact_name="",
contact_phone="",
address="原材料库",
)
table_y = _draw_archive_page_frame(fake_pdf, context, "Helvetica", "Helvetica-Bold", 1, 1)
metadata_lines = [
(text, font_name, size)
for x, y, text, font_name, size in fake_pdf.drawn_texts
if x == 42 and y > A4[1] - 190
]
document_no_lines = [text for text, _, _ in metadata_lines if "单据编号" in text or "返工追溯" in text]
self.assertGreaterEqual(len(document_no_lines), 2)
self.assertLess(table_y, A4[1] - 190)
for text, font_name, size in metadata_lines:
rendered_width = pdfmetrics.stringWidth(text, font_name, size)
self.assertLessEqual(rendered_width, 288)
def test_generate_archive_failed_row_uses_free_version_after_collision(self) -> None:
order = self._seed_sales_order()