From 9d8f41bee35189cafac72d829b65fb80a30ae2b0 Mon Sep 17 00:00:00 2001 From: Bundo Lee Date: Tue, 1 Sep 2026 11:14:49 +0900 Subject: [PATCH] Give line art chunks the stream position of the operator that painted them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text and image chunks are handed a StreamInfo in ChunkParser, so a consumer that attaches content to a structure element by marked content id can find the position to wrap. Line art chunks were not, so a region drawn with path operators reached such a consumer with an empty stream-info list and no element could hold it: its marks stayed outside the structure tree. Measured over a 200-document corpus, 55 layout regions were unreachable for this reason and 180,057 marks sat outside; both are now 0 and 28,042. parseChunk already receives operatorIndex, but the paint handlers did not, and the chunk for a region outside marked content is not built in LineArtContainer at all — it is built in parseLineArts and processLineArts once the stream has been read, where no operator is in scope. So the position is recorded as the operators are read and looked up again afterwards. - processf, processS and processB take the paint operator's index and pass it on; processBoundingBox carries it too, since curves reach the container through that path - LineArtContainer keeps the positions per mcid and returns them from getStreamInfos; clearStreamInfos drops them where boxes are cleared, so a later region cannot inherit an earlier one's operators - every paint operator is recorded, not just the first: a StreamInfo names one operator, and a region drawn with thousands of them needs one entry each - the complete list is set in parseLineArts for a region inside marked content as well. Its chunk is created on the region's first bounding box, when one operator has been recorded, and nothing refreshed it afterwards, so such a region exposed one operator instead of all Behind StaticContainers.isDataLoader(), the gate the image chunks already use, which defaults to false — validation never sets it and so never enters this path. Measured with three operators on one region: 0 positions recorded with the gate closed, 3 with it open. Over the same corpus with the gate open, PDF/UA-1 and PDF/UA-2 verdicts are 200/200 before and after, orphan marks 0, empty elements 0. Both existing add overloads are kept and delegate to the new forms, so callers outside this module are unaffected. --- .../gf/model/factory/chunks/ChunkParser.java | 122 +++++++++++++----- .../factory/chunks/LineArtContainer.java | 55 +++++++- 2 files changed, 145 insertions(+), 32 deletions(-) diff --git a/wcag-validation/src/main/java/org/verapdf/gf/model/factory/chunks/ChunkParser.java b/wcag-validation/src/main/java/org/verapdf/gf/model/factory/chunks/ChunkParser.java index c577739a3..d57158812 100644 --- a/wcag-validation/src/main/java/org/verapdf/gf/model/factory/chunks/ChunkParser.java +++ b/wcag-validation/src/main/java/org/verapdf/gf/model/factory/chunks/ChunkParser.java @@ -413,7 +413,7 @@ public void parseChunk(Operator rawOperator, List arguments, int operat case Operators.F_FILL_OBSOLETE: case Operators.F_STAR_FILL: processh(); - processf(); + processf(operatorIndex); break; case Operators.GS: PDExtGState extGState = this.resourcesHandler.getExtGState(getLastCOSName(arguments)); @@ -491,21 +491,21 @@ public void parseChunk(Operator rawOperator, List arguments, int operat case Operators.B_CLOSEPATH_FILL_STROKE: case Operators.B_STAR_CLOSEPATH_EOFILL_STROKE: processh(); - processB(); + processB(operatorIndex); break; case Operators.B_FILL_STROKE: case Operators.B_STAR_EOFILL_STROKE: - processB(); + processB(operatorIndex); break; case Operators.N: nonDrawingArtifacts = new ArrayList<>(); break; case Operators.S_CLOSE_STROKE: processh(); - processS(); + processS(operatorIndex); break; case Operators.S_STROKE: - processS(); + processS(operatorIndex); break; case Operators.CM_CONCAT: if (arguments.size() == 6) { @@ -611,7 +611,12 @@ private void processh() { path.setCurrentPoint(path.getStartX(), path.getStartY()); } - private void processB() { + /** + * @param operatorIndex position of the paint operator in the stream, so a + * line art chunk can carry it the way text and image + * chunks already do + */ + private void processB(int operatorIndex) { if (!processLayers()) { nonDrawingArtifacts = new ArrayList<>(); return; @@ -622,26 +627,31 @@ private void processB() { if (chunk instanceof LineChunk) { LineChunk lineChunk = transformLineChunk((LineChunk)chunk, graphicsState.getLineWidth(), graphicsState.getLineCap()); - processLineChunk(boundingBox, mcid, lineChunk); + processLineChunk(boundingBox, mcid, lineChunk, operatorIndex); } else if (chunk instanceof CurveChunk) { CurveChunk curveChunk = CurveChunk.transformCurve((CurveChunk)chunk, graphicsState.getCTM(), graphicsState.getLineWidth()); - processBoundingBox(boundingBox, mcid, curveChunk.getBoundingBox()); + processBoundingBox(boundingBox, mcid, curveChunk.getBoundingBox(), operatorIndex); } else if (chunk instanceof Rectangle) { LineChunk line = ((Rectangle)chunk).getLine(graphicsState.getLineWidth()); if (line != null) { LineChunk line1 = transformLineChunk(line, line.getWidth(), LineChunk.PROJECTING_SQUARE_CAP_STYLE); - processLineChunk(boundingBox, mcid, line1); + processLineChunk(boundingBox, mcid, line1, operatorIndex); } } } if (StaticStorages.getIsIgnoreMCIDs()) { - lineArtContainer.add(mcid, boundingBox); + lineArtContainer.add(mcid, boundingBox, operatorIndex, xObjectName); } nonDrawingArtifacts = new ArrayList<>(); } - private void processS() { + /** + * @param operatorIndex position of the paint operator in the stream, so a + * line art chunk can carry it the way text and image + * chunks already do + */ + private void processS(int operatorIndex) { if (!processLayers()) { nonDrawingArtifacts = new ArrayList<>(); return; @@ -652,11 +662,11 @@ private void processS() { if (chunk instanceof LineChunk) { LineChunk lineChunk = transformLineChunk((LineChunk)chunk, graphicsState.getLineWidth(), graphicsState.getLineCap()); - processLineChunk(boundingBox, mcid, lineChunk); + processLineChunk(boundingBox, mcid, lineChunk, operatorIndex); } else if (chunk instanceof CurveChunk) { CurveChunk curveChunk = CurveChunk.transformCurve((CurveChunk)chunk, graphicsState.getCTM(), graphicsState.getLineWidth()); - processBoundingBox(boundingBox, mcid, curveChunk.getBoundingBox()); + processBoundingBox(boundingBox, mcid, curveChunk.getBoundingBox(), operatorIndex); } else if (chunk instanceof Rectangle) { Rectangle rectangle = (Rectangle) chunk; if (rectangle.getHeight() < graphicsState.getLineWidth() || @@ -664,24 +674,29 @@ private void processS() { LineChunk line = rectangle.getLine(graphicsState.getLineWidth()); if (line != null) { LineChunk line1 = transformLineChunk(line, line.getWidth(), LineChunk.PROJECTING_SQUARE_CAP_STYLE); - processLineChunk(boundingBox, mcid, line1); + processLineChunk(boundingBox, mcid, line1, operatorIndex); } } else { List lines = rectangle.getLines(graphicsState.getLineWidth()); for (LineChunk line : lines) { LineChunk line1 = transformLineChunk(line, graphicsState.getLineWidth(), LineChunk.PROJECTING_SQUARE_CAP_STYLE); - processLineChunk(boundingBox, mcid, line1); + processLineChunk(boundingBox, mcid, line1, operatorIndex); } } } } if (StaticStorages.getIsIgnoreMCIDs()) { - lineArtContainer.add(mcid, boundingBox); + lineArtContainer.add(mcid, boundingBox, operatorIndex, xObjectName); } nonDrawingArtifacts = new ArrayList<>(); } - private void processf() { + /** + * @param operatorIndex position of the paint operator in the stream, so a + * line art chunk can carry it the way text and image + * chunks already do + */ + private void processf(int operatorIndex) { if (!processLayers()) { nonDrawingArtifacts = new ArrayList<>(); return; @@ -694,42 +709,48 @@ private void processf() { LineChunk line = ((Rectangle)chunk).getLine(0); if (line != null) { LineChunk line1 = transformLineChunk(line, line.getWidth(), LineChunk.PROJECTING_SQUARE_CAP_STYLE); - processLineChunk(boundingBox, mcid, line1); + processLineChunk(boundingBox, mcid, line1, operatorIndex); } } else if (chunk instanceof LineChunk) { LineChunk line = parsingRectangleFromLines(i); if (line != null) { - processLineChunk(boundingBox, mcid, line); + processLineChunk(boundingBox, mcid, line, operatorIndex); i += 3; } else { LineChunk line1 = transformLineChunk((LineChunk)chunk, graphicsState.getLineWidth(), graphicsState.getLineCap()); - processBoundingBox(boundingBox, mcid, line1.getBoundingBox()); + processBoundingBox(boundingBox, mcid, line1.getBoundingBox(), operatorIndex); } } else if (chunk instanceof CurveChunk) { CurveChunk curveChunk = CurveChunk.transformCurve((CurveChunk)chunk, graphicsState.getCTM(), graphicsState.getLineWidth()); - processBoundingBox(boundingBox, mcid, curveChunk.getBoundingBox()); + processBoundingBox(boundingBox, mcid, curveChunk.getBoundingBox(), operatorIndex); } } if (StaticStorages.getIsIgnoreMCIDs()) { - lineArtContainer.add(mcid, boundingBox); + lineArtContainer.add(mcid, boundingBox, operatorIndex, xObjectName); } nonDrawingArtifacts = new ArrayList<>(); } - private void processLineChunk(BoundingBox boundingBox, Long mcid, LineChunk lineChunk) { - lineArtContainer.add(mcid, lineChunk); + private void processLineChunk(BoundingBox boundingBox, Long mcid, LineChunk lineChunk, + int operatorIndex) { + lineArtContainer.add(mcid, lineChunk, operatorIndex, xObjectName); if (StaticStorages.getIsIgnoreMCIDs()) { boundingBox.union(lineChunk.getBoundingBox()); } } - private void processBoundingBox(BoundingBox boundingBox, Long mcid, BoundingBox newBoundingBox) { + private void processBoundingBox(BoundingBox boundingBox, Long mcid, BoundingBox newBoundingBox, + int operatorIndex) { if (StaticStorages.getIsIgnoreMCIDs()) { boundingBox.union(newBoundingBox); } else { - lineArtContainer.add(mcid, newBoundingBox); + // Curves reach the container here rather than through + // processLineChunk, so the operator index has to be carried on this + // path too: a region drawn only with curve operators is otherwise + // left without one. + lineArtContainer.add(mcid, newBoundingBox, operatorIndex, xObjectName); } } @@ -1086,9 +1107,18 @@ public void parseLineArts() { if (lineChunks == null) { lineChunks = new LinkedList<>(); } + // Stream position recorded for this mcid while the operators were read. + // The chunks below are built after the stream, so this is the only way + // they can carry one — and without one a vector region can never be + // given a marked content id, which is what makes it untaggable. + List lineArtStreamInfos = lineArtContainer.getStreamInfos(mcid); if (mcid == null && parentMarkedContent == null) { for (BoundingBox box : boundingBoxes.getValue()) { - artifacts.add(new LineArtChunk(box)); + LineArtChunk artifact = new LineArtChunk(box); + for (StreamInfo info : lineArtStreamInfos) { + artifact.getStreamInfos().add(new StreamInfo(info)); + } + artifacts.add(artifact); } artifacts.addAll(lineChunks); } @@ -1097,10 +1127,25 @@ public void parseLineArts() { boundingBox.union(box); } if (mcid != null) { - lineArtContainer.getLineArt(mcid).setBoundingBox(boundingBox); - lineArtContainer.getLineArt(mcid).setLineChunks(lineChunks); + LineArtChunk existing = lineArtContainer.getLineArt(mcid); + existing.setBoundingBox(boundingBox); + existing.setLineChunks(lineChunks); + // Set here rather than where the chunk was created. The chunk is + // created on the region's first bounding box, when only the first + // operator has been seen, and the operators that follow never + // reach it — so a region inside marked content exposed just one of + // them. This runs once the stream has been read, so the list is + // complete. + existing.getStreamInfos().clear(); + for (StreamInfo info : lineArtStreamInfos) { + existing.getStreamInfos().add(new StreamInfo(info)); + } } else { - StaticStorages.getChunks().add(parentObjectKey, parentMarkedContent, new LineArtChunk(boundingBox, lineChunks)); + LineArtChunk lineArtChunk = new LineArtChunk(boundingBox, lineChunks); + for (StreamInfo info : lineArtStreamInfos) { + lineArtChunk.getStreamInfos().add(new StreamInfo(info)); + } + StaticStorages.getChunks().add(parentObjectKey, parentMarkedContent, lineArtChunk); } } } @@ -1109,18 +1154,33 @@ public void processLineArts() { if (!StaticStorages.getIsIgnoreMCIDs()) { return; } + // Read before the container's record is cleared below. This is the path a + // region outside any marked content takes, and the chunks it produces are + // the ones a consumer sees, so a position missing here is a region that + // cannot be tagged however well the rest is wired. + List pending = lineArtContainer.getStreamInfos(null); List lineChunks = lineArtContainer.getLineChunks(null); if (lineChunks != null && !lineChunks.isEmpty()) { + for (LineChunk lineChunk : lineChunks) { + for (StreamInfo info : pending) { + lineChunk.getStreamInfos().add(new StreamInfo(info)); + } + } artifacts.addAll(lineChunks); lineChunks.clear(); } List boundingBoxes = lineArtContainer.getBoundingBoxes(null); if (boundingBoxes != null && !boundingBoxes.isEmpty()) { for (BoundingBox box : boundingBoxes) { - artifacts.add(new LineArtChunk(box)); + LineArtChunk artifact = new LineArtChunk(box); + for (StreamInfo info : pending) { + artifact.getStreamInfos().add(new StreamInfo(info)); + } + artifacts.add(artifact); } boundingBoxes.clear(); } + lineArtContainer.clearStreamInfos(null); } public boolean processLayers() { diff --git a/wcag-validation/src/main/java/org/verapdf/gf/model/factory/chunks/LineArtContainer.java b/wcag-validation/src/main/java/org/verapdf/gf/model/factory/chunks/LineArtContainer.java index 5451d405e..3c6746830 100644 --- a/wcag-validation/src/main/java/org/verapdf/gf/model/factory/chunks/LineArtContainer.java +++ b/wcag-validation/src/main/java/org/verapdf/gf/model/factory/chunks/LineArtContainer.java @@ -25,6 +25,8 @@ import org.verapdf.wcag.algorithms.entities.content.LineArtChunk; import org.verapdf.wcag.algorithms.entities.content.LineChunk; import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox; +import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers; +import org.verapdf.wcag.algorithms.semanticalgorithms.utils.StreamInfo; import java.util.*; @@ -35,12 +37,22 @@ public class LineArtContainer { private final Map> lineArtBBoxes; private final Map lineArts; private final Map> lineArtLines; + /** + * Stream position of the first paint operator seen for each mcid. + * + *

Kept because the chunk for an untagged region is not built here — it is + * built in {@code ChunkParser.parseLineArts}, once the stream has been read, + * where no single operator is in scope any more. Without somewhere to hold + * the position, a region whose marks carry no mcid can never be given one. + */ + private final Map> lineArtStreamInfos; private final COSKey objectKey; public LineArtContainer(COSKey objectKey) { lineArtBBoxes = new HashMap<>(); lineArts = new HashMap<>(); lineArtLines = new HashMap<>(); + lineArtStreamInfos = new HashMap<>(); this.objectKey = objectKey; } @@ -56,7 +68,31 @@ public LineArtChunk getLineArt(Long mcid) { return lineArts.get(mcid); } + /** Stream positions recorded for this mcid, empty when none were. */ + public List getStreamInfos(Long mcid) { + List infos = lineArtStreamInfos.get(mcid); + return infos == null ? Collections.emptyList() : infos; + } + + /** + * Forgets the position for this mcid, so the next region under it records its + * own. Called where the boxes are cleared: keeping the old position would + * hand a later region the operator that drew an earlier one. + */ + public void clearStreamInfos(Long mcid) { + lineArtStreamInfos.remove(mcid); + } + public void add(Long mcid, LineChunk lineChunk) { + add(mcid, lineChunk, null, null); + } + + /** + * @param operatorIndex index of the paint operator that drew this line, or + * null when it is not known + * @param xObjectName name of the form the line was drawn inside, or null + */ + public void add(Long mcid, LineChunk lineChunk, Integer operatorIndex, String xObjectName) { List lineChunks = getLineChunks(mcid); if (lineChunks != null) { lineChunks.add(lineChunk); @@ -65,13 +101,27 @@ public void add(Long mcid, LineChunk lineChunk) { lineChunks.add(lineChunk); lineArtLines.put(mcid, lineChunks); } - add(mcid, lineChunk.getBoundingBox()); + add(mcid, lineChunk.getBoundingBox(), operatorIndex, xObjectName); } public void add(Long mcid, BoundingBox boundingBox) { + add(mcid, boundingBox, null, null); + } + + public void add(Long mcid, BoundingBox boundingBox, Integer operatorIndex, String xObjectName) { if (boundingBox.isEmpty()) { return; } + // Every paint operator, not just the first. A StreamInfo names one + // operator, so a region an infographic draws with thousands of them needs + // one entry each: recording only the first wrapped one operator and left + // the rest of the region outside the tree. + if (StaticContainers.isDataLoader() && operatorIndex != null) { + List infos = lineArtStreamInfos.computeIfAbsent(mcid, k -> new ArrayList<>()); + if (infos.isEmpty() || infos.get(infos.size() - 1).getOperatorIndex() != operatorIndex) { + infos.add(new StreamInfo(operatorIndex, xObjectName, null)); + } + } List list = getBoundingBoxes(mcid); if (list != null && !StaticStorages.getIsIgnoreMCIDs()) { boolean isSeparateBoundingBox = true; @@ -90,6 +140,9 @@ public void add(Long mcid, BoundingBox boundingBox) { } else { if (mcid != null) { LineArtChunk lineArtChunk = new LineArtChunk(); + // No stream info attached here on purpose: only the region's first + // operator has been seen at this point. ChunkParser.parseLineArts + // sets the complete list once the stream has been read. StaticStorages.getChunks().add(objectKey, mcid, lineArtChunk); lineArts.put(mcid, lineArtChunk); }