Skip to content

Commit 927838e

Browse files
committed
[ITB-1753] Support page break placeholders in custom messages of PDF reports
1 parent a4becbb commit 927838e

4 files changed

Lines changed: 121 additions & 7 deletions

File tree

gitb-reports/src/main/java/com/gitb/reports/ReportGenerator.java

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@
5050
import org.apache.commons.lang3.Strings;
5151
import org.jsoup.Jsoup;
5252
import org.jsoup.helper.W3CDom;
53+
import org.jsoup.nodes.Document;
54+
import org.jsoup.nodes.Element;
55+
import org.jsoup.nodes.TextNode;
5356
import org.slf4j.Logger;
5457
import org.slf4j.LoggerFactory;
5558

@@ -72,6 +75,8 @@ public class ReportGenerator {
7275

7376
private static final Logger LOG = LoggerFactory.getLogger(ReportGenerator.class);
7477
private static final ReportGenerator INSTANCE = new ReportGenerator();
78+
private static final String PAGE_BREAK_PLACEHOLDER = "$PAGE_BREAK";
79+
private static final String REPORT_MESSAGE_SELECTOR = "div.report-message";
7580
private final JAXBContext jaxbContext;
7681
private final Map<String, Template> templateCache;
7782
private final Map<String, TemplateMethodModelEx> extensionFunctions;
@@ -115,6 +120,106 @@ private Template getTemplate(String reportPath) {
115120
});
116121
}
117122

123+
private boolean hasPageBreakPlaceholder(Boolean includeMessage, String message) {
124+
return Boolean.TRUE.equals(includeMessage) && message != null && message.contains(PAGE_BREAK_PLACEHOLDER);
125+
}
126+
127+
/**
128+
* Resolves "$PAGE_BREAK" placeholder tokens found within the custom report message block(s) (marked with the
129+
* "report-message" CSS class in the certificate templates) into CSS page breaks:
130+
* - If an element follows the placeholder (within the message), "page-break-before: always;" is applied to it.
131+
* - If the placeholder is trailing (nothing follows it within the message), "page-break-after: always;" is
132+
* applied to the message container itself, so that the report content following the message starts on a new page.
133+
* The placeholder text (and its surrounding whitespace) is removed in all cases.
134+
*/
135+
private void processPageBreaks(Document doc) {
136+
for (Element container : doc.select(REPORT_MESSAGE_SELECTOR)) {
137+
processPageBreaksInContainer(container);
138+
}
139+
}
140+
141+
private void processPageBreaksInContainer(Element container) {
142+
TextNode targetNode;
143+
while ((targetNode = findTextNodeWithPlaceholder(container)) != null) {
144+
Element anchor = (parentOf(targetNode) instanceof Element parentElement) ? parentElement : container;
145+
String updatedText = targetNode.getWholeText().replaceAll("\\s*\\Q"+PAGE_BREAK_PLACEHOLDER+"\\E\\s*", " ").trim();
146+
if (updatedText.isEmpty()) {
147+
targetNode.remove();
148+
} else {
149+
targetNode.text(updatedText);
150+
}
151+
// Determine the next element to break before, scoped to the message container's own content.
152+
Element nextElement = findNextElementWithin(anchor, container);
153+
if (anchor != container && isEffectivelyEmpty(anchor)) {
154+
// The placeholder was on its own line/block - drop the now-empty wrapper.
155+
anchor.remove();
156+
}
157+
if (nextElement != null) {
158+
addPageBreakStyle(nextElement, "page-break-before");
159+
} else if (!isEffectivelyEmpty(container)) {
160+
addPageBreakStyle(container, "page-break-after");
161+
}
162+
}
163+
}
164+
165+
private TextNode findTextNodeWithPlaceholder(Element root) {
166+
for (var child : root.childNodes()) {
167+
if (child instanceof TextNode textNode) {
168+
if (textNode.getWholeText().contains(PAGE_BREAK_PLACEHOLDER)) {
169+
return textNode;
170+
}
171+
} else if (child instanceof Element childElement) {
172+
TextNode found = findTextNodeWithPlaceholder(childElement);
173+
if (found != null) {
174+
return found;
175+
}
176+
}
177+
}
178+
return null;
179+
}
180+
181+
private Element findNextElementWithin(Element anchor, Element container) {
182+
Element current = anchor;
183+
while (current != null && current != container) {
184+
Element sibling = current.nextElementSibling();
185+
if (sibling != null) {
186+
return sibling;
187+
}
188+
current = (parentOf(current) instanceof Element parentElement) ? parentElement : null;
189+
}
190+
return null;
191+
}
192+
193+
/**
194+
* Equivalent to node.parent(), but resolved through the base org.jsoup.nodes.Node#parent() (which always
195+
* returns Node) rather than through the Element/LeafNode-level overrides that (depending on the jsoup version)
196+
* covariantly narrow the return type to Element. Binding to those overrides at compile time can otherwise
197+
* produce a NoSuchMethodError if the runtime classpath resolves an older jsoup jar without the narrower override.
198+
*/
199+
private org.jsoup.nodes.Node parentOf(org.jsoup.nodes.Node node) {
200+
return node.parent();
201+
}
202+
203+
private boolean isEffectivelyEmpty(Element element) {
204+
return element.text().trim().isEmpty() && element.children().isEmpty();
205+
}
206+
207+
private void addPageBreakStyle(Element element, String property) {
208+
String existingStyle = element.attr("style").trim();
209+
if (existingStyle.contains(property)) {
210+
return;
211+
}
212+
StringBuilder styleBuilder = new StringBuilder(existingStyle);
213+
if (!styleBuilder.isEmpty() && styleBuilder.charAt(styleBuilder.length() - 1) != ';') {
214+
styleBuilder.append(';');
215+
}
216+
if (!styleBuilder.isEmpty()) {
217+
styleBuilder.append(' ');
218+
}
219+
styleBuilder.append(property).append(": always;");
220+
element.attr("style", styleBuilder.toString());
221+
}
222+
118223
private void loadFonts(PdfRendererBuilder builder) {
119224
builder.useFont(() -> Thread.currentThread().getContextClassLoader().getResourceAsStream("fonts/FreeSans/FreeSans.ttf"), "FreeSans", 400, BaseRendererBuilder.FontStyle.NORMAL, true);
120225
builder.useFont(() -> Thread.currentThread().getContextClassLoader().getResourceAsStream("fonts/FreeSans/FreeSansBold.ttf"), "FreeSans", 700, BaseRendererBuilder.FontStyle.NORMAL, true);
@@ -131,6 +236,10 @@ private void loadFonts(PdfRendererBuilder builder) {
131236
}
132237

133238
public void writeClasspathReport(String reportPath, Map<String, Object> parameters, OutputStream outputStream, ReportSpecs specs) {
239+
writeClasspathReport(reportPath, parameters, outputStream, specs, false);
240+
}
241+
242+
private void writeClasspathReport(String reportPath, Map<String, Object> parameters, OutputStream outputStream, ReportSpecs specs, boolean applyPageBreaks) {
134243
ReportSpecs specsToUse = Objects.requireNonNullElseGet(specs, ReportSpecs::build);
135244
// Add custom extension functions.
136245
parameters = Objects.requireNonNullElse(parameters, new HashMap<>());
@@ -176,11 +285,13 @@ public String resolveURI(String baseUri, String uri) {
176285
}
177286
});
178287

179-
if (tempHtmlFile != null) {
180-
builder.withW3cDocument(new W3CDom().fromJsoup(Jsoup.parse(tempHtmlFile, StandardCharsets.UTF_8.name())), "reports");
181-
} else {
182-
builder.withW3cDocument(new W3CDom().fromJsoup(Jsoup.parse(tempHtmlString)), "reports");
288+
var doc = (tempHtmlFile != null)
289+
? Jsoup.parse(tempHtmlFile, StandardCharsets.UTF_8.name())
290+
: Jsoup.parse(tempHtmlString);
291+
if (applyPageBreaks) {
292+
processPageBreaks(doc);
183293
}
294+
builder.withW3cDocument(new W3CDom().fromJsoup(doc), "reports");
184295

185296
builder.toStream(outputStream);
186297
builder.run();
@@ -422,7 +533,7 @@ public void writeConformanceOverviewReport(ConformanceOverview overview, OutputS
422533
try {
423534
Map<String, Object> parameters = new HashMap<>();
424535
parameters.put("data", overview);
425-
writeClasspathReport("reports/ConformanceOverview.ftl", parameters, outputStream, specs);
536+
writeClasspathReport("reports/ConformanceOverview.ftl", parameters, outputStream, specs, hasPageBreakPlaceholder(overview.getIncludeMessage(), overview.getMessage()));
426537
} catch (Exception e) {
427538
throw new IllegalStateException("Unexpected error while generating report", e);
428539
}
@@ -436,7 +547,7 @@ public void writeConformanceStatementOverviewReport(ConformanceStatementOverview
436547
try {
437548
Map<String, Object> parameters = new HashMap<>();
438549
parameters.put("data", overview);
439-
writeClasspathReport("reports/ConformanceStatementOverview.ftl", parameters, outputStream, specs);
550+
writeClasspathReport("reports/ConformanceStatementOverview.ftl", parameters, outputStream, specs, hasPageBreakPlaceholder(overview.getIncludeMessage(), overview.getMessage()));
440551
} catch (Exception e) {
441552
throw new IllegalStateException("Unexpected error while generating report", e);
442553
}

gitb-ui/ui/src/app/common/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ export class Constants {
311311
public static readonly PLACEHOLDER__LAST_UPDATE_DATE = "$LAST_UPDATE_DATE"
312312
public static readonly PLACEHOLDER__REPORT_DATE = "$REPORT_DATE"
313313
public static readonly PLACEHOLDER__SNAPSHOT = "$SNAPSHOT"
314+
public static readonly PLACEHOLDER__PAGE_BREAK = "$PAGE_BREAK"
314315

315316
public static readonly TEST_STATUS = {
316317
UNKNOWN: null,

gitb-ui/ui/src/app/pages/admin/user-management/community-reports/conformance-certificate-form/conformance-certificate-form.component.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ export class ConformanceCertificateFormComponent extends BaseCertificateSettings
6262
{ key: Constants.PLACEHOLDER__BADGE, value: 'The badge image corresponding to the current conformance status (original image size).'},
6363
{ key: Constants.PLACEHOLDER__BADGE+'{width}', value: 'The badge image corresponding to the current conformance status (with fixed width in pixels).', select: () => Constants.PLACEHOLDER__BADGE+'{100}' },
6464
{ key: Constants.PLACEHOLDER__REPORT_DATE+'{format}', value: 'The report generation date (with date format).', select: () => Constants.PLACEHOLDER__REPORT_DATE+'{dd/MM/yyyy}' },
65-
{ key: Constants.PLACEHOLDER__LAST_UPDATE_DATE+'{format}', value: 'The conformance last update time (with date format).', select: () => Constants.PLACEHOLDER__LAST_UPDATE_DATE+'{dd/MM/yyyy}' }
65+
{ key: Constants.PLACEHOLDER__LAST_UPDATE_DATE+'{format}', value: 'The conformance last update time (with date format).', select: () => Constants.PLACEHOLDER__LAST_UPDATE_DATE+'{dd/MM/yyyy}' },
66+
{ key: Constants.PLACEHOLDER__PAGE_BREAK, value: 'A page break that can be used to force a new page at the specific location.' }
6667
]
6768
}
6869

gitb-ui/ui/src/app/pages/admin/user-management/community-reports/conformance-overview-certificate-form/conformance-overview-certificate-form.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ export class ConformanceOverviewCertificateFormComponent extends BaseCertificate
279279
placeholders.push({ key: Constants.PLACEHOLDER__BADGES+'{layout|width}', value: 'The list of all conformance badges (with fixed width in pixels) using a \'horizontal\' (the default) or \'vertical\' layout.', select: () => Constants.PLACEHOLDER__BADGES+'{horizontal|100}'})
280280
placeholders.push({ key: Constants.PLACEHOLDER__REPORT_DATE+'{format}', value: 'The report generation date (with date format).', select: () => Constants.PLACEHOLDER__REPORT_DATE+'{dd/MM/yyyy}' })
281281
placeholders.push({ key: Constants.PLACEHOLDER__LAST_UPDATE_DATE+'{format}', value: 'The conformance last update time (with date format).', select: () => Constants.PLACEHOLDER__LAST_UPDATE_DATE+'{dd/MM/yyyy}' })
282+
placeholders.push({ key: Constants.PLACEHOLDER__PAGE_BREAK, value: 'A page break that can be used to force a new page at the specific location.' })
282283
return placeholders
283284
}
284285

0 commit comments

Comments
 (0)