feat: add Java JRXML-to-PNG rendering pipeline with pixel-level SSIM comparison

- lib/java/: Java renderer (JrxmlRenderer) using JasperReports 6.21.0
  - JrxmlDebug for diagnostics, JrxmlGen for format reference
  - download_jars.sh for one-time dependency setup
- agent/nodes.py: _render_jrxml_to_png() and _compute_pixel_similarity()
  - Pixel comparison integrates into validate node (SSIM < 0.4 fails)
  - Pixel fidelity context injected into correct_jrxml for targeted fixes
- tests/test_pixel_comparison.py: 15 unit tests (render, SSIM, integration)
- .gitignore: exclude lib/java/*.jar, lib/java/*.class, tmp/
- CLAUDE.md: v11 changelog documenting the rendering pipeline
- All non-LLM tests pass (97/97)
This commit is contained in:
2026-05-23 15:09:55 +08:00
parent 9de75d2f25
commit bb6cc6e241
16 changed files with 837 additions and 8 deletions
+7
View File
@@ -20,6 +20,13 @@ EVALUATION_REPORT.md
# 上传文件
uploads/
# Java JARs & compiled classes
lib/java/*.jar
lib/java/*.class
# 渲染临时文件
tmp/
# OCR 临时输出
ocr_raw_positions.json
+42
View File
@@ -385,3 +385,45 @@ cd frontend && npx playwright test
**MAX_RETRY 调整**: 默认值从 3 → 5(环境变量 `MAX_RETRY`),配合续写机制确保复杂报表有充分修正机会。
**JRXML 提取命名空间兼容**: `_extract_jrxml()``_generate_with_continuation()` 的完整性检查统一支持 `</ns0:jasperReport>` 等命名空间前缀闭合标签。
## 更新 (v11 — 2026-05-23)
### Java 渲染管线 + 像素级对比
**目标**: 将 JRXML 渲染为 PNG 图片,与用户上传的原始图片进行 SSIM(结构相似性)像素级对比。
**Java 依赖** (`lib/java/`):
| JAR | 用途 |
|-----|------|
| `jasperreports-6.21.0.jar` (5.8MB) | 核心库,**必须用 6.x**7.x 仅支持 Jackson XML 格式) |
| `commons-digester-2.1.jar` | XML 解析(6.x 使用 Digester 2.x |
| `commons-logging-1.3.5.jar`, `commons-collections4-4.5.0.jar`, `commons-beanutils-1.10.1.jar`, `commons-lang3-3.17.0.jar` | 基础依赖 |
| `itext-2.1.7.jar` | PDF 生成 |
| `jfreechart-1.5.5.jar` | 图表 |
| `ecj-3.38.0.jar` | Eclipse JDT 编译器(报表表达式编译) |
**Java 工具** (`lib/java/`):
| 文件 | 用途 |
|------|------|
| `JrxmlRenderer.java` | JRXML → PNG 渲染器 |
| `JrxmlDebug.java` | 诊断:SAX/JRXmlLoader/compile 三层测试 |
| `JrxmlGen.java` | 参考:程序化构建 JasperDesign → 序列化为 XML |
**Python 渲染封装** (`agent/nodes.py`):
- `_render_jrxml_to_png(jrxml, output_path, scale)` — 调用 Java `JrxmlRenderer`
- `_compute_pixel_similarity(rendered_png, reference_image)` — OpenCV + scikit-image SSIM 对比
**像素对比流程**: validate 节点 XSD 通过 → 有 `uploaded_file_path` → Java 渲染 → SSIM 对比 → SSIM < 0.4 且 diff > 60% → 标记 fail → 注入 correct_jrxml 修正上下文
**手动渲染**: `java -cp ".;jasperreports-6.21.0.jar;..." JrxmlRenderer input.jrxml output.png 2.0`
### 内容保真度 + 修正去重 (v10 补充)
- `_check_ocr_fidelity(jrxml, state)` — OCR 字段名/元素数/列数三重检查
- `correct_jrxml` 去重检测:输入输出相同 → `retry_count += 2`
- `prompts/correction.md` — 一次只修复第1个错误 + 输出不可与输入相同 + 命名空间严格指定
- `prompts/skeleton_generation.md`, `prompts/modification.md` — 明确命名空间约束
### consult_answer 前端显示修复
- `api_server.py``agent_complete` SSE 事件新增 `consult_answer` 字段
+266 -2
View File
@@ -839,6 +839,188 @@ def modify_jrxml(state: AgentState) -> Dict:
return state
# ── Java renderer config ──────────────────────────────────────────────
_JAVA_BIN = os.path.join(
os.environ.get("JAVA_HOME", "C:/Program Files/Java/jdk-21.0.11"),
"bin", "java.exe"
)
_JAVA_JAR_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "lib", "java")
_JAVA_RENDERER_CP = ";".join([
os.path.join(_JAVA_JAR_DIR, j) for j in [
"jasperreports-6.21.0.jar",
"commons-logging-1.3.5.jar",
"commons-collections4-4.5.0.jar",
"commons-beanutils-1.10.1.jar",
"commons-lang3-3.17.0.jar",
"commons-digester-2.1.jar",
"itext-2.1.7.jar",
"jfreechart-1.5.5.jar",
"ecj-3.38.0.jar",
]
])
_JAVA_RENDERER_CLASS = "JrxmlRenderer"
_JAVA_RENDERER_CP = "." + os.pathsep + _JAVA_RENDERER_CP
def _render_jrxml_to_png(jrxml: str, output_path: str, scale: float = 2.0) -> bool:
"""调用 Java JrxmlRenderer 将 JRXML 渲染为 PNG。
返回 True 表示渲染成功,False 表示失败。
"""
import subprocess
import tempfile
tmpdir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tmp")
os.makedirs(tmpdir, exist_ok=True)
jrxml_path = os.path.join(tmpdir, "_render_input.jrxml")
with open(jrxml_path, "w", encoding="utf-8") as f:
f.write(jrxml)
try:
result = subprocess.run(
[_JAVA_BIN, "-cp", _JAVA_RENDERER_CP, _JAVA_RENDERER_CLASS,
jrxml_path, output_path, str(scale)],
capture_output=True, text=True, timeout=120,
cwd=_JAVA_JAR_DIR,
)
if result.returncode == 0:
_node_log.info(f"PNG rendered: {output_path} ({result.stdout.strip()})")
return True
else:
_node_log.warning(f"PNG render failed: {result.stdout.strip()} {result.stderr.strip()}")
return False
except Exception as e:
_node_log.warning(f"PNG render exception: {e}")
return False
def _compute_pixel_similarity(rendered_png: str, reference_image: str) -> dict:
"""计算渲染 PNG 与参考图片的像素级相似度。
使用 SSIM(结构相似性)作为主要指标,同时返回像素差异比例。
返回 {"ssim": float, "diff_pct": float, "error": str|None}
"""
try:
import cv2
import numpy as np
rendered = cv2.imread(rendered_png, cv2.IMREAD_GRAYSCALE)
reference = cv2.imread(reference_image, cv2.IMREAD_GRAYSCALE)
if rendered is None:
return {"ssim": 0.0, "diff_pct": 1.0, "error": f"无法读取渲染图片: {rendered_png}"}
if reference is None:
return {"ssim": 0.0, "diff_pct": 1.0, "error": f"无法读取参考图片: {reference_image}"}
# Resize rendered to match reference dimensions for comparison
if rendered.shape != reference.shape:
rendered = cv2.resize(rendered, (reference.shape[1], reference.shape[0]))
# SSIM
from skimage.metrics import structural_similarity as ssim
score = ssim(rendered, reference, data_range=255)
# Pixel difference percentage
diff = cv2.absdiff(rendered, reference)
diff_pct = float(np.count_nonzero(diff > 30)) / diff.size
return {"ssim": round(score, 4), "diff_pct": round(diff_pct, 4), "error": None}
except ImportError as e:
return {"ssim": 0.0, "diff_pct": 1.0, "error": f"缺少依赖: {e}"}
except Exception as e:
return {"ssim": 0.0, "diff_pct": 1.0, "error": str(e)}
def _check_ocr_fidelity(jrxml: str, state: dict) -> dict:
"""比对生成的 JRXML 与原始图片 OCR 提取内容的保真度。
检查维度:
1. 字段覆盖:OCR 字段名是否在 JRXML <field> 声明中出现
2. 元素数量:JRXML 中 textField+staticText 数量与 OCR 文本元素数量之比
3. 列结构:data band 中的列数与 OCR 检测到的列数比对
"""
ocr_elements = state.get("ocr_elements", [])
ocr_result = state.get("ocr_extraction_result", {})
layout_schema = state.get("layout_schema", {})
# 无 OCR 数据时跳过
if not ocr_elements and not ocr_result:
return {"score": 1.0, "field_coverage": 1.0, "element_coverage": 1.0, "issues": []}
issues = []
# 1. 元素数量对比
text_fields = len(re.findall(r"<textField", jrxml))
static_texts = len(re.findall(r"<staticText", jrxml))
total_jrxml_elements = text_fields + static_texts
ocr_text_count = 0
if isinstance(ocr_elements, list):
ocr_text_count = len([e for e in ocr_elements if isinstance(e, dict) and e.get("text", "").strip()])
if ocr_text_count == 0 and isinstance(ocr_result, dict):
ocr_text_count = ocr_result.get("total_elements", 0)
if ocr_text_count > 0:
element_coverage = min(total_jrxml_elements / max(ocr_text_count, 1), 1.0)
if element_coverage < 0.3:
issues.append(
f"元素覆盖不足:JRXML 仅有 {total_jrxml_elements} 个文本元素,"
f"OCR 源有 {ocr_text_count} 个文本元素(覆盖率 {element_coverage:.0%}"
)
else:
element_coverage = 1.0
# 2. 字段名覆盖
jrxml_fields = set(re.findall(r'<field name="(\w+)"', jrxml))
ocr_field_names = set()
ocr_fields = ocr_result.get("fields", []) if isinstance(ocr_result, dict) else []
for f in ocr_fields:
if isinstance(f, dict):
name = f.get("name", "") or f.get("field_name", "") or f.get("label", "")
if name and len(name) > 1:
ocr_field_names.add(name)
if ocr_field_names and jrxml_fields:
matched = jrxml_fields & ocr_field_names
field_coverage = len(matched) / max(len(ocr_field_names), 1)
unmatched = ocr_field_names - jrxml_fields
if unmatched:
sample = list(unmatched)[:8]
issues.append(f"OCR 字段未在 JRXML 中声明: {', '.join(sample)}")
elif ocr_field_names and not jrxml_fields:
field_coverage = 0.0
issues.append("JRXML 中未声明任何字段,但 OCR 提取了结构化字段数据")
else:
field_coverage = 1.0
# 3. 列数对比
if isinstance(layout_schema, dict):
ocr_columns = layout_schema.get("total_columns", 0) or layout_schema.get("columns", 0)
# 从 detail band 中的元素 x 坐标估算列数
detail_match = re.search(r"<band[^>]*height=\"(\d+)\"[^>]*>([\s\S]*?)</band>", jrxml)
if detail_match and ocr_columns > 0:
detail_content = detail_match.group(2)
x_positions = set()
for m in re.finditer(r'x="(\d+)"', detail_content):
x_positions.add(int(m.group(1)))
jrxml_columns = len(x_positions) if x_positions else 1
if jrxml_columns < ocr_columns * 0.5:
issues.append(
f"列数不足:JRXML detail band 检测到 {jrxml_columns} 列,"
f"OCR 布局分析有 {ocr_columns}"
)
# 综合评分
score = round(field_coverage * 0.5 + element_coverage * 0.5, 3)
return {
"score": score,
"field_coverage": round(field_coverage, 3),
"element_coverage": round(element_coverage, 3),
"issues": issues,
}
@log_node("validate")
def validate(state: AgentState) -> Dict:
"""根据 FastAPI 验证服务验证当前 JRXML。"""
@@ -866,6 +1048,57 @@ def validate(state: AgentState) -> Dict:
state["status"] = "pass" if result.get("valid") else "fail"
state["error_msg"] = result.get("error", "")
# OCR 保真度检查:比对生成结果与原始图片的 OCR 提取内容
fidelity = _check_ocr_fidelity(jrxml, state)
state["ocr_fidelity"] = fidelity
if fidelity["issues"]:
if state["status"] == "pass":
# XSD 通过但内容保真度不足 → 降级为 fail
if fidelity["score"] < 0.5:
state["status"] = "fail"
state["error_msg"] = (
f"[内容保真度不足] 得分 {fidelity['score']:.2f}/1.0。"
+ " ".join(fidelity["issues"][:3])
)
_node_log.warning(
f"OCR 保真度得分 {fidelity['score']:.2f}XSD 通过但内容差异过大: "
+ "; ".join(fidelity["issues"][:5])
)
else:
_node_log.info(
f"OCR 保真度得分 {fidelity['score']:.2f}XSD 通过,轻微差异: "
+ "; ".join(fidelity["issues"][:3])
)
else:
_node_log.info(
f"XSD 验证失败 + OCR 保真度得分 {fidelity['score']:.2f}: "
+ "; ".join(fidelity["issues"][:3])
)
# ── 像素级对比:将 JRXML 渲染为 PNG,与原始上传图片进行 SSIM 比较 ──
source_image = state.get("uploaded_file_path", "")
if source_image and os.path.isfile(source_image) and state["status"] == "pass":
tmpdir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tmp")
rendered_png = os.path.join(tmpdir, "_pixel_test.png")
if _render_jrxml_to_png(jrxml, rendered_png):
pixel_result = _compute_pixel_similarity(rendered_png, source_image)
state["pixel_fidelity"] = pixel_result
if pixel_result["error"]:
_node_log.warning(f"像素对比失败: {pixel_result['error']}")
else:
_node_log.info(
f"像素对比: SSIM={pixel_result['ssim']:.4f}, "
f"Diff={pixel_result['diff_pct']:.2%}"
)
# SSIM < 0.4 或 diff > 60% → 质量不合格
if pixel_result["ssim"] < 0.4 and pixel_result["diff_pct"] > 0.6:
state["status"] = "fail"
state["error_msg"] = (
f"[像素保真度不足] SSIM={pixel_result['ssim']:.3f}, "
f"差异像素占比={pixel_result['diff_pct']:.2%}"
f"渲染结果与原始图片差异过大,需调整布局。"
)
# 修正成功后记录到错误知识库
if result.get("valid") and state.get("retry_count", 0) > 0:
case = state.get("last_error_case", {})
@@ -920,12 +1153,34 @@ def correct_jrxml(state: AgentState) -> Dict:
layout_text = ""
if isinstance(layout_schema, dict):
layout_text = layout_schema.get("schema_text", "")
# 构建保真度上下文(告诉 LLM 图片与模板的差异)
fidelity = state.get("ocr_fidelity", {})
fidelity_text = ""
if fidelity and fidelity.get("score", 1.0) < 0.9:
fidelity_text = (
f"[内容保真度警告] 得分 {fidelity.get('score', 0):.2f}/1.0\n"
+ "\n".join(f"- {issue}" for issue in fidelity.get("issues", []))
)
# 像素级对比上下文
pixel_fidelity = state.get("pixel_fidelity", {})
if pixel_fidelity and pixel_fidelity.get("ssim", 1.0) < 0.7:
fidelity_parts = [fidelity_text] if fidelity_text else []
fidelity_parts.append(
f"[像素保真度] SSIM={pixel_fidelity.get('ssim', 0):.4f}, "
f"像素差异={pixel_fidelity.get('diff_pct', 0):.2%}"
f"渲染结果与原图差异过大,请调整元素位置、尺寸和布局。"
)
fidelity_text = "\n".join(fidelity_parts)
prompt = load_prompt("correction").format(
current_jrxml=state.get("current_jrxml", ""),
error_msg=state.get("error_msg", ""),
explanation=state.get("natural_explanation", ""),
ocr_context=ocr_context,
layout_schema_text=layout_text,
fidelity_context=fidelity_text,
)
# 保存修正前状态(供 validate 判断是否写入错误知识库)
state["last_error_case"] = {
@@ -944,8 +1199,17 @@ def correct_jrxml(state: AgentState) -> Dict:
if len(jrxml.strip()) < 200:
_node_log.warning(f"correct_jrxml 输出过短({len(jrxml)} 字符),回退到前一版本")
jrxml = prev_jrxml
state["current_jrxml"] = jrxml
state["retry_count"] = state.get("retry_count", 0) + 1
# 去重检测:如果输出与输入完全相同(忽略空白差异),说明修正无效
_prev_norm = re.sub(r"\s+", "", prev_jrxml) if prev_jrxml else ""
_new_norm = re.sub(r"\s+", "", jrxml) if jrxml else ""
if _prev_norm and _new_norm and _prev_norm == _new_norm:
_node_log.warning(
f"correct_jrxml 输出与输入完全相同({len(jrxml)} 字符),修正无效,加速消耗 retry"
)
state["retry_count"] = state.get("retry_count", 0) + 2
else:
state["retry_count"] = state.get("retry_count", 0) + 1
state["conversation_history"].append(
{"role": "assistant", "content": f"[自动修正,第 {state['retry_count']} 次尝试]\n{jrxml}"}
)
+1
View File
@@ -244,6 +244,7 @@ async def _sse_generator(agent_state: AgentState, session_id: str = "") -> str:
"jrxml_length": len(agent_state.get("current_jrxml", "")),
"error_msg": agent_state.get("error_msg", ""),
"natural_explanation": agent_state.get("natural_explanation", ""),
"consult_answer": agent_state.get("consult_answer", ""),
"retry_count": agent_state.get("retry_count", 0),
"total_duration_ms": total_ms,
"ocr_extraction_result": agent_state.get("ocr_extraction_result", {}),
+7 -2
View File
@@ -70,6 +70,7 @@ async function handleSend(text: string, files: File[]) {
jrxml_length: data.jrxml_length,
error_msg: data.error_msg,
natural_explanation: data.natural_explanation,
consult_answer: data.consult_answer,
retry_count: data.retry_count,
total_duration_ms: data.total_duration_ms,
ocr_extraction_result: data.ocr_extraction_result,
@@ -88,8 +89,12 @@ async function handleSend(text: string, files: File[]) {
type: 'error',
})
} else if (data.intent === 'consult_question') {
if (streamContent) {
chat.addMessage({ role: 'assistant', content: streamContent, type: 'consult' })
// streamContent consult_answer
const answerText = streamContent || data.consult_answer || ''
if (answerText) {
chat.addMessage({ role: 'assistant', content: answerText, type: 'consult' })
} else {
chat.addMessage({ role: 'assistant', content: '咨询已完成,但未获取到回答内容。', type: 'error' })
}
} else {
if (streamContent) {
+1
View File
@@ -27,6 +27,7 @@ export interface AgentCompleteData {
jrxml_length: number
error_msg: string
natural_explanation: string
consult_answer: string
retry_count: number
total_duration_ms: number
ocr_extraction_result: any
+2 -1
View File
@@ -144,7 +144,7 @@ export const useChatStore = defineStore('chat', () => {
function finishStreaming(data?: {
intent?: string; status?: string; jrxml_length?: number
error_msg?: string; natural_explanation?: string; retry_count?: number
error_msg?: string; natural_explanation?: string; consult_answer?: string; retry_count?: number
total_duration_ms?: number; ocr_extraction_result?: any
}) {
streaming.value = false
@@ -164,6 +164,7 @@ export const useChatStore = defineStore('chat', () => {
jrxml_length: data.jrxml_length || 0,
error_msg: data.error_msg || '',
natural_explanation: data.natural_explanation || '',
consult_answer: data.consult_answer || '',
retry_count: data.retry_count || 0,
}
if (data.ocr_extraction_result) {
+47
View File
@@ -0,0 +1,47 @@
import net.sf.jasperreports.engine.*;
import net.sf.jasperreports.engine.design.*;
import net.sf.jasperreports.engine.xml.*;
import java.io.*;
public class JrxmlDebug {
public static void main(String[] args) throws Exception {
String path = args.length > 0 ? args[0] : "D:/Idea Project/jaspersoft/tmp/test_simple.jrxml";
File f = new File(path);
System.out.println("File: " + path + " (exists=" + f.exists() + ", len=" + f.length() + ")");
// Test 1: JRXmlLoader.load()
System.out.println("\n=== JRXmlLoader.load() ===");
try {
JasperDesign design = JRXmlLoader.load(f);
System.out.println("PASS: " + design.getName()
+ " pages=" + design.getPageWidth() + "x" + design.getPageHeight());
System.out.println(" Title: " + (design.getTitle() != null ? design.getTitle().getHeight() + "px" : "null"));
System.out.println(" Detail: " + (design.getDetailSection() != null ? "present" : "null"));
} catch (Throwable t) {
System.out.println("FAIL: " + t.getMessage());
Throwable c = t;
int d = 0;
while (c != null) {
System.out.println(" [" + d + "] " + c.getClass().getName() + ": " + c.getMessage());
for (int i = 0; i < Math.min(5, c.getStackTrace().length); i++)
System.out.println(" at " + c.getStackTrace()[i]);
c = c.getCause();
d++;
}
}
// Test 2: JasperCompileManager.compileReport()
System.out.println("\n=== JasperCompileManager.compileReport() ===");
try {
JasperReport report = JasperCompileManager.compileReport(path);
System.out.println("PASS: " + report.getName());
} catch (Throwable t) {
System.out.println("FAIL: " + t.getMessage());
Throwable c = t;
while (c != null) {
System.out.println(" -> " + c.getClass().getName() + ": " + c.getMessage());
c = c.getCause();
}
}
}
}
+58
View File
@@ -0,0 +1,58 @@
import net.sf.jasperreports.engine.*;
import net.sf.jasperreports.engine.design.*;
import net.sf.jasperreports.jackson.util.*;
import java.io.*;
/**
* Generate a minimal JasperDesign programmatically, then serialize it
* via JacksonUtil to show the correct 7.x XML format.
*/
public class JrxmlGen {
public static void main(String[] args) throws Exception {
JasperDesign design = new JasperDesign();
design.setName("TestReport");
design.setPageWidth(595);
design.setPageHeight(842);
design.setColumnWidth(555);
design.setLeftMargin(20);
design.setRightMargin(20);
design.setTopMargin(20);
design.setBottomMargin(20);
design.setWhenNoDataType(com.fasterxml.jackson.databind.node.TextNode.valueOf("AllSectionsNoDetail"));
design.setQuery("SELECT 1");
JRDesignBand titleBand = new JRDesignBand();
titleBand.setHeight(50);
JRDesignStaticText st = new JRDesignStaticText();
st.setX(0);
st.setY(0);
st.setWidth(555);
st.setHeight(30);
st.setText("HELLO WORLD");
titleBand.addElement(st);
design.setTitle(titleBand);
JRDesignBand detailBand = new JRDesignBand();
detailBand.setHeight(20);
JRDesignStaticText dt = new JRDesignStaticText();
dt.setX(0);
dt.setY(0);
dt.setWidth(555);
dt.setHeight(20);
dt.setText("test row");
detailBand.addElement(dt);
design.setDetail(detailBand);
JacksonUtil util = JacksonUtil.getInstance(DefaultJasperReportsContext.getInstance());
String xml = util.saveXml(design);
System.out.println("=== Serialized 7.x JRXML ===");
System.out.println(xml);
String outPath = "D:/Idea Project/jaspersoft/tmp/test_reference.jrxml";
try (FileWriter fw = new FileWriter(outPath)) {
fw.write(xml);
}
System.out.println("\nSaved to: " + outPath);
}
}
+110
View File
@@ -0,0 +1,110 @@
import net.sf.jasperreports.engine.*;
import net.sf.jasperreports.engine.export.*;
import net.sf.jasperreports.export.*;
import java.io.*;
import java.util.*;
import java.awt.image.*;
import javax.imageio.*;
/**
* Minimal JRXML PNG renderer for pixel-level fidelity comparison.
* Usage: java JrxmlRenderer input.jrxml output.png [scale]
* scale: optional zoom factor (default 2.0 for readable text)
*
* Uses JasperReports 7.x exporter API (SimpleExporterInput / SimpleGraphics2DExporterOutput).
*/
public class JrxmlRenderer {
public static void main(String[] args) throws Exception {
if (args.length < 2) {
System.err.println("Usage: java JrxmlRenderer <input.jrxml> <output.png> [scale]");
System.exit(1);
}
String jrxmlPath = args[0];
String outputPath = args[1];
float scale = args.length >= 3 ? Float.parseFloat(args[2]) : 2.0f;
File jrxmlFile = new File(jrxmlPath);
if (!jrxmlFile.exists()) {
System.err.println("ERROR: JRXML file not found: " + jrxmlPath);
System.exit(2);
}
try {
// 1. Compile JRXML JasperReport
JasperReport report = JasperCompileManager.compileReport(jrxmlPath);
// 2. Fill with empty data source
Map<String, Object> params = new HashMap<>();
JasperPrint print = JasperFillManager.fillReport(report, params, new JREmptyDataSource());
int pages = print.getPages().size();
if (pages == 0) {
System.err.println("ERROR: Report has 0 pages after filling");
System.exit(4);
}
// 3. Calculate image dimensions from page size
// JasperReports uses 72 DPI, convert to pixels with scale
int pageWidth = (int) Math.ceil(report.getPageWidth() * scale);
int pageHeight = (int) Math.ceil(report.getPageHeight() * scale);
// 4. Render each page to a BufferedImage
java.util.List<BufferedImage> pageImages = new ArrayList<>();
for (int i = 0; i < pages; i++) {
BufferedImage pageImage = new BufferedImage(pageWidth, pageHeight, BufferedImage.TYPE_INT_RGB);
java.awt.Graphics2D g2d = pageImage.createGraphics();
g2d.setColor(java.awt.Color.WHITE);
g2d.fillRect(0, 0, pageWidth, pageHeight);
// Scale the graphics context
g2d.scale(scale, scale);
JRGraphics2DExporter exporter = new JRGraphics2DExporter();
exporter.setExporterInput(new SimpleExporterInput(print));
SimpleGraphics2DExporterOutput output = new SimpleGraphics2DExporterOutput();
output.setGraphics2D(g2d);
exporter.setExporterOutput(output);
SimpleGraphics2DReportConfiguration config = new SimpleGraphics2DReportConfiguration();
config.setPageIndex(i);
exporter.setConfiguration(config);
exporter.exportReport();
g2d.dispose();
pageImages.add(pageImage);
}
// 5. Combine pages into single tall image
int totalHeight = 0;
for (BufferedImage img : pageImages) {
totalHeight += img.getHeight();
}
BufferedImage combined = new BufferedImage(pageWidth, totalHeight, BufferedImage.TYPE_INT_RGB);
java.awt.Graphics2D g = combined.createGraphics();
g.setColor(java.awt.Color.WHITE);
g.fillRect(0, 0, pageWidth, totalHeight);
int yOffset = 0;
for (BufferedImage img : pageImages) {
g.drawImage(img, 0, yOffset, null);
yOffset += img.getHeight();
}
g.dispose();
// 6. Write PNG
ImageIO.write(combined, "png", new File(outputPath));
System.out.println("OK: " + outputPath
+ " (pages=" + pages
+ ", size=" + pageWidth + "x" + totalHeight
+ ", scale=" + scale + ")");
} catch (JRException e) {
System.err.println("JASPER_ERROR: " + e.getMessage());
e.printStackTrace();
System.exit(3);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
import net.sf.jasperreports.engine.*;
import net.sf.jasperreports.engine.design.*;
import net.sf.jasperreports.engine.xml.*;
import java.io.*;
public class JrxmlRendererTest {
public static void main(String[] args) throws Exception {
String path = args.length > 0 ? args[0] : "/tmp/test_render.jrxml";
File f = new File(path);
System.out.println("File: " + f.getAbsolutePath() + " exists=" + f.exists() + " len=" + f.length());
// Read content to check XML validity
try (BufferedReader br = new BufferedReader(new FileReader(f))) {
String line;
int lineNum = 0;
while ((line = br.readLine()) != null && lineNum < 5) {
System.out.println(" L" + (++lineNum) + ": " + line);
}
}
try {
System.out.println("Step 1: Loading JRXML...");
JasperDesign design = JRXmlLoader.load(f);
System.out.println(" OK - name=" + design.getName());
} catch (JRException e) {
System.err.println("JR ERROR: " + e.getMessage());
e.printStackTrace();
} catch (Exception e) {
System.err.println("GENERIC ERROR: " + e.getClass().getName() + ": " + e.getMessage());
e.printStackTrace();
} catch (Error e) {
System.err.println("FATAL ERROR: " + e.getClass().getName() + ": " + e.getMessage());
e.printStackTrace();
}
}
}
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# Download JasperReports 6.21.0 and dependencies for JRXML-to-PNG rendering.
# Run this once after cloning the repo.
set -e
BASE="https://repo1.maven.org/maven2"
JARS=(
"net/sf/jasperreports/jasperreports/6.21.0/jasperreports-6.21.0.jar"
"commons-logging/commons-logging/1.3.5/commons-logging-1.3.5.jar"
"org/apache/commons/commons-collections4/4.5.0/commons-collections4-4.5.0.jar"
"commons-beanutils/commons-beanutils/1.10.1/commons-beanutils-1.10.1.jar"
"org/apache/commons/commons-lang3/3.17.0/commons-lang3-3.17.0.jar"
"commons-digester/commons-digester/2.1/commons-digester-2.1.jar"
"com/lowagie/itext/2.1.7/itext-2.1.7.jar"
"org/jfree/jfreechart/1.5.5/jfreechart-1.5.5.jar"
"org/eclipse/jdt/ecj/3.38.0/ecj-3.38.0.jar"
)
for jar in "${JARS[@]}"; do
fname=$(basename "$jar")
if [ -f "$fname" ]; then
echo "SKIP: $fname (exists)"
else
echo "DOWNLOAD: $fname"
curl -sL -o "$fname" "$BASE/$jar"
fi
done
echo ""
echo "All JARs ready. Compile with:"
echo " javac -cp \"jasperreports-6.21.0.jar;...\" JrxmlRenderer.java"
echo " java -cp \".;jasperreports-6.21.0.jar;...\" JrxmlRenderer input.jrxml output.png 2.0"
+5 -1
View File
@@ -3,10 +3,12 @@
关键规则:
- 只输出完整修复后的 JRXML 代码,不要解释,不要 markdown 标记。
- JRXML 必须与 JasperReports 7.0.6 兼容。
- 解决下面列出的特定错误。
- **一次只修复一个错误**:关注错误列表中的第 1 个错误,修复它即可。不要尝试修复所有错误。
- **输出不能与输入相同**:如果你发现自己要输出和当前 JRXML 完全相同的代码,说明你没有修复错误——必须做出实质性改动。
- 如果当前 JRXML 内容为空或过短(<200 字符),请根据下方提供的 OCR 识别数据和布局 schema 重新生成完整的 JRXML,而非输出一个占位桩。
- 如果错误是"字段 'field_N' 未在 <field> 部分声明"**必须**为每个缺失的 field_N 添加 `<field name="field_N" class="java.lang.String"/>` 声明。这些是占位字段,不可删除。同时确保所有 $F{{field_N}} 引用都有对应的 <field> 声明。
- 如果错误是"字段 'field_N' 未在 <field> 部分声明"且有 OCR 字段数据,尝试将 $F{{field_N}} 替换为 OCR 中对应的真实字段名(如 $F{{invoice_code}}),同时更新 <field> 声明和所有引用。
- **如果错误包含 "No matching global declaration available for the validation root" 或 "namespace" 或 "命名空间"**,说明命名空间 URL 错误。正确的根元素格式必须为:`<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd">`。删除所有 ns0: 前缀,删除所有 `xmlns:ns0` 声明,删除所有元素标签上的 `ns0:` 前缀。
当前 JRXML(带错误):
{current_jrxml}
@@ -21,4 +23,6 @@
{layout_schema_text}
{fidelity_context}
立即生成修正后的 JRXML
+1 -1
View File
@@ -3,7 +3,7 @@
关键规则:
- 只输出完整修改后的 JRXML 代码,不要解释,不要 markdown 标记。
- 保留所有未被更改的现有结构。
- 结果必须继续与 JasperReports 7.0.6 兼容。
- 结果必须继续与 JasperReports 7.0.6 兼容。命名空间必须为 `xmlns="http://jasperreports.sourceforge.net/jasperreports"`,不可使用 jaspersoft.com 等错误 URL。
- 报表正文中使用的每个字段必须在 <field> 部分中声明。
- 如果添加新字段,正确声明它们。
- 确保 <queryString> 是 <![CDATA[...]]> 中有效的 SQL。
+1 -1
View File
@@ -5,7 +5,7 @@
- 使用 $F{{field_1}}, $F{{field_2}}, ... 作为占位字段名,并在 <field> 部分声明它们。
- 报表结构必须正确(title, pageHeader, columnHeader, detail, pageFooter 等 band)。
- 元素位置使用近似值即可,后续会精确调整。
- 根元素为 <jasperReport>,包含正确的 xmlns 属性
- 根元素为 <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd">,命名空间和 schemaLocation 必须精确,不可使用其他 URL(如 jaspersoft.com
- 包含 <queryString>,在 <![CDATA[...]]> 中放置占位 SQLSELECT * FROM table_name)。
- 确保 JRXML 兼容 JasperReports 7.0.6。
+220
View File
@@ -0,0 +1,220 @@
"""Tests for pixel-level JRXML-to-image comparison pipeline."""
import os
import sys
import tempfile
import pytest
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv()
import cv2
from agent.nodes import _render_jrxml_to_png, _compute_pixel_similarity, _check_ocr_fidelity
# ── Test JRXML fixture ─────────────────────────────────────────────────
VALID_JRXML = """<?xml version="1.0" encoding="UTF-8"?>
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd"
name="TestReport" pageWidth="595" pageHeight="842"
columnWidth="555" leftMargin="20" rightMargin="20"
topMargin="20" bottomMargin="20">
<queryString><![CDATA[SELECT 1]]></queryString>
<title>
<band height="50">
<staticText>
<reportElement x="0" y="0" width="555" height="30"/>
<text><![CDATA[HELLO]]></text>
</staticText>
</band>
</title>
<detail>
<band height="20">
<staticText>
<reportElement x="0" y="0" width="555" height="20"/>
<text><![CDATA[test]]></text>
</staticText>
</band>
</detail>
</jasperReport>"""
INVALID_JRXML = "<notJRXML></notJRXML>"
# ── _render_jrxml_to_png ────────────────────────────────────────────────
class TestRenderJrxmlToPng:
def test_valid_jrxml_renders_successfully(self, tmp_path):
output_png = str(tmp_path / "valid_output.png")
result = _render_jrxml_to_png(VALID_JRXML, output_png, scale=1.0)
assert result, "Valid JRXML should render successfully"
assert os.path.exists(output_png), f"Output PNG should exist at {output_png}"
assert os.path.getsize(output_png) > 1000, "Output PNG should be non-trivial"
def test_invalid_jrxml_returns_false(self, tmp_path):
output_png = str(tmp_path / "invalid_output.png")
result = _render_jrxml_to_png(INVALID_JRXML, output_png)
assert not result, "Invalid JRXML should return False"
def test_render_output_is_readable_image(self, tmp_path):
output_png = str(tmp_path / "readable.png")
_render_jrxml_to_png(VALID_JRXML, output_png, scale=1.0)
img = cv2.imread(output_png)
assert img is not None, "Rendered PNG should be readable by OpenCV"
assert img.shape[0] > 0 and img.shape[1] > 0, "Image should have non-zero dimensions"
assert img.shape[2] == 3, "Should be a 3-channel (BGR) image"
def test_high_scale_produces_larger_image(self, tmp_path):
png1 = str(tmp_path / "scale1.png")
png2 = str(tmp_path / "scale2.png")
_render_jrxml_to_png(VALID_JRXML, png1, scale=1.0)
_render_jrxml_to_png(VALID_JRXML, png2, scale=2.0)
img1 = cv2.imread(png1)
img2 = cv2.imread(png2)
assert img2.shape[0] >= img1.shape[0], "Higher scale should produce >= height"
assert img2.shape[1] >= img1.shape[1], "Higher scale should produce >= width"
# ── _compute_pixel_similarity ───────────────────────────────────────────
class TestComputePixelSimilarity:
def test_identical_images_have_high_ssim(self, tmp_path):
img_path = str(tmp_path / "test.png")
white = np.full((100, 200, 3), 255, dtype=np.uint8)
cv2.imwrite(img_path, white)
result = _compute_pixel_similarity(img_path, img_path)
assert result["error"] is None, f"Should have no error: {result['error']}"
assert result["ssim"] == 1.0, f"SSIM of identical images should be 1.0, got {result['ssim']}"
assert result["diff_pct"] == 0.0, f"Diff% of identical images should be 0, got {result['diff_pct']}"
def test_completely_different_images_have_low_ssim(self, tmp_path):
white_path = str(tmp_path / "white.png")
black_path = str(tmp_path / "black.png")
cv2.imwrite(white_path, np.full((100, 200, 3), 255, dtype=np.uint8))
cv2.imwrite(black_path, np.full((100, 200, 3), 0, dtype=np.uint8))
result = _compute_pixel_similarity(white_path, black_path)
assert result["error"] is None, f"Should have no error: {result['error']}"
assert result["ssim"] < 0.3, f"Different images should have low SSIM, got {result['ssim']}"
assert result["diff_pct"] > 0.9, f"Different images should have high diff%, got {result['diff_pct']}"
def test_different_size_images_are_resized(self, tmp_path):
img1_path = str(tmp_path / "img1.png")
img2_path = str(tmp_path / "img2.png")
cv2.imwrite(img1_path, np.full((50, 100, 3), 128, dtype=np.uint8))
cv2.imwrite(img2_path, np.full((100, 200, 3), 128, dtype=np.uint8))
result = _compute_pixel_similarity(img1_path, img2_path)
assert result["error"] is None, f"Resize should work: {result['error']}"
def test_missing_file_returns_error(self, tmp_path):
result = _compute_pixel_similarity(
str(tmp_path / "does_not_exist.png"),
str(tmp_path / "also_missing.png"),
)
assert result["error"] is not None, "Missing files should set error"
assert result["ssim"] == 0.0
def test_non_image_file_returns_error(self, tmp_path):
text_path = str(tmp_path / "text.txt")
with open(text_path, "w") as f:
f.write("not an image")
png_path = str(tmp_path / "ref.png")
cv2.imwrite(png_path, np.full((100, 100, 3), 255, dtype=np.uint8))
result = _compute_pixel_similarity(text_path, png_path)
assert result["error"] is not None, "Non-image should set error"
# ── _check_ocr_fidelity ─────────────────────────────────────────────────
class TestCheckOcrFidelity:
def test_no_ocr_data_returns_full_score(self):
state = {}
result = _check_ocr_fidelity(VALID_JRXML, state)
assert result["score"] == 1.0
assert result["issues"] == []
def test_missing_ocr_fields_flagged(self):
state = {
"ocr_extraction_result": {
"fields": [
{"name": "invoice_code"},
{"name": "invoice_number"},
{"name": "amount"},
]
}
}
result = _check_ocr_fidelity(VALID_JRXML, state)
assert result["field_coverage"] < 1.0, "Missing fields should reduce coverage"
assert len(result["issues"]) > 0, "Should have issues about missing fields"
def test_matched_fields_increase_coverage(self):
state = {
"ocr_extraction_result": {
"fields": [
{"name": "invoice_code"},
{"name": "invoice_number"},
]
}
}
jrxml_with_fields = """<?xml version="1.0" encoding="UTF-8"?>
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports"
name="TestReport" pageWidth="595" pageHeight="842"
columnWidth="555" leftMargin="20" rightMargin="20"
topMargin="20" bottomMargin="20">
<field name="invoice_code" class="java.lang.String"/>
<field name="invoice_number" class="java.lang.String"/>
<queryString><![CDATA[SELECT 1]]></queryString>
<detail><band height="20"><textField><reportElement x="0" y="0" width="200" height="20"/><textFieldExpression><![CDATA[$F{invoice_code}]]></textFieldExpression></textField></band></detail>
</jasperReport>"""
result = _check_ocr_fidelity(jrxml_with_fields, state)
assert result["field_coverage"] == 1.0, f"All fields matched, got {result['field_coverage']}"
assert len(result["issues"]) == 0, f"No issues expected, got {result['issues']}"
def test_element_count_mismatch_flagged(self):
state = {
"ocr_elements": [
{"text": "a"}, {"text": "b"}, {"text": "c"},
{"text": "d"}, {"text": "e"},
]
}
result = _check_ocr_fidelity(VALID_JRXML, state)
assert result["element_coverage"] < 1.0, \
"Fewer JRXML elements than OCR should reduce coverage"
# ── validate node integration ───────────────────────────────────────────
class TestValidatePixelIntegration:
def test_validate_skips_pixel_when_no_image(self):
from agent.nodes import validate
state = {
"current_jrxml": VALID_JRXML,
"uploaded_file_path": None,
"ocr_elements": [],
"layout_schema": {},
"conversation_history": [],
}
result = validate(state)
assert result.get("pixel_fidelity") is None, \
"Should not set pixel_fidelity without uploaded_file_path"
assert result["status"] == "pass", "Valid JRXML should pass XSD"
def test_validate_skips_pixel_when_xsd_fails(self):
from agent.nodes import validate
state = {
"current_jrxml": INVALID_JRXML,
"uploaded_file_path": os.path.join(
os.path.dirname(os.path.dirname(__file__)), "tmp", "test_output.png"
),
"ocr_elements": [],
"layout_schema": {},
"conversation_history": [],
}
result = validate(state)
assert result["status"] == "fail", "Invalid JRXML should fail XSD"