Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified assets/readme/examples/cv-charcoal-gold-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-midnight-navy-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-navy-sidebar-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-professional-sidebar-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-serif-headline-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-teal-pulse-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-terracotta-rail-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-violet-grid-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/invoice-consulting-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/invoice-luma-studio-v2.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package com.demcha.compose.document.templates;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.style.DocumentLetterSpacing;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.templates.cv.presets.ProfessionalSidebar;
import com.demcha.compose.document.templates.cv.presets.ProfessionalSidebarFixtures;
import com.demcha.compose.document.templates.invoice.presets.ConsultingInvoice;
import com.demcha.compose.document.templates.invoice.presets.ConsultingInvoiceFixtures;
import com.demcha.compose.document.templates.invoice.presets.LumaStudioInvoice;
import com.demcha.compose.document.templates.invoice.presets.LumaStudioInvoiceFixtures;
import com.demcha.compose.document.templates.receipt.presets.ModernReceipt;
import com.demcha.compose.document.templates.receipt.presets.ReceiptFixtures;
import com.demcha.compose.document.templates.rota.presets.CobaltRota;
import com.demcha.compose.document.templates.rota.presets.CobaltRotaFixtures;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static org.assertj.core.api.Assertions.assertThat;

/**
* The text layer of the promoted templates says the words they draw.
*
* <p>Ten shipped presets once made their tracking by writing something that is
* not the text — a space between every letter, a hair space, a run of sized
* spacers, an invisible rectangle — so a heading drawn as PROFILE came out of
* the file as {@code P R O F I L E} and a name could not be searched for. The
* engine tracks with the pen and states the run's own {@code ActualText}, so
* the words survive. This gate holds them to that.</p>
*
* <p>The CV family is covered preset by preset in
* {@code CvPresetTextLayerTest}; this one covers the other families the defect
* reached, and pins the three labels by name.</p>
*
* <p>The detector is asserted against fixtures of its own below. That matters
* more than it looks: the first sweep of this defect was run on a PDFBox that
* ignores {@code ActualText}, and it called twenty-two correct templates
* broken. A detector nobody has tried to fool is a detector nobody should
* trust.</p>
*/
class TemplateTextLayerGateTest {

/**
* Three or more single letters, each separated by one space character.
*
* <p>Any space separator counts, not just {@code U+0020}: one of the
* presets spelled its headings with hair and thin spaces, which a plain
* space class reads straight past.</p>
*/
private static final Pattern SPELLED_OUT =
Pattern.compile("(?<!\\p{L})(?:\\p{L}[\\p{Zs}]){2,}\\p{L}(?!\\p{L})");

// -- the shipped templates ---------------------------------------------

@Test
void theConsultingInvoiceSaysBilled() throws Exception {
String extracted = extract(session ->
ConsultingInvoice.create().compose(
session, ConsultingInvoiceFixtures.canonicalInvoice()));

assertThat(extracted).contains("BILLED");
assertThat(extracted).doesNotContain("B I L L E D");
assertThat(spelledOutRuns(extracted)).isEmpty();
}

@Test
void theLumaStudioInvoiceSaysInvoice() throws Exception {
String extracted = extract(session ->
LumaStudioInvoice.create().compose(
session, LumaStudioInvoiceFixtures.canonicalInvoice()));

assertThat(extracted).contains("INVOICE");
assertThat(extracted).doesNotContain("I N V O I C E");
assertThat(spelledOutRuns(extracted)).isEmpty();
}

@Test
void theProfessionalSidebarSaysProfile() throws Exception {
String extracted = extract(session ->
ProfessionalSidebar.create().compose(
session, ProfessionalSidebarFixtures.canonicalCv()));

assertThat(extracted).contains("PROFILE");
assertThat(extracted).doesNotContain("P R O F I L E");
assertThat(spelledOutRuns(extracted)).isEmpty();
}

/**
* The brand qualifier is tracked; the rules that flank it are not.
*
* <p>Reached only when the brand carries no logo, which no committed
* preview does — so this is the one place the text lockup is drawn at all.
* The tracking belongs to the word: applying it to the whole run would
* spread the two rules away from the word they point at, which is a
* styling change rather than the text-layer fix, and centre alignment
* hides it from the layout snapshot.</p>
*/
@Test
void onlyTheBrandQualifierIsTracked() throws Exception {
String extracted = extract(session ->
ConsultingInvoice.create().compose(
session, ConsultingInvoiceFixtures.logolessInvoice()));

assertThat(extracted).contains("NORTHPOINT");
assertThat(extracted).contains("CONSULTING");
assertThat(spelledOutRuns(extracted)).isEmpty();
}

/** The rota family's only preset, which no committed preview renders. */
@Test
void theRotaReadsBackAsWords() throws Exception {
String extracted = extract(session ->
CobaltRota.create().compose(session, CobaltRotaFixtures.canonicalRota()));

assertThat(spelledOutRuns(extracted)).isEmpty();
}

/**
* The control. This preset never faked its tracking — it has carried
* native spaced caps from the day it shipped — so it is the case that must
* keep passing. If a change to the detector ever reddens this one, the
* detector is wrong, not the receipt.
*/
@Test
void theReceiptsNativeSpacedCapsStayClean() throws Exception {
String extracted = extract(session ->
ModernReceipt.create().compose(session, ReceiptFixtures.canonicalReceipt()));

assertThat(extracted).contains("AMOUNT COLLECTED");
assertThat(spelledOutRuns(extracted)).isEmpty();
}

// -- the detector itself -----------------------------------------------

@Test
void theDetectorCatchesLettersSpelledOutWithOrdinarySpaces() throws Exception {
String extracted = extract(session -> session.pageFlow(page -> page
.addParagraph("P R O F I L E")));

assertThat(spelledOutRuns(extracted)).contains("P R O F I L E");
}

/**
* The failure one preset actually shipped: the gaps were hair and thin
* spaces, so a detector written against {@code " "} alone reads the line
* as a single word and passes it.
*/
@Test
void theDetectorCatchesLettersSpelledOutWithHairAndThinSpaces() throws Exception {
String hair = "P R O F I L E";
String thin = "B I L L E D";

assertThat(spelledOutRuns(hair)).isNotEmpty();
assertThat(spelledOutRuns(thin)).isNotEmpty();
}

@Test
void theDetectorPassesAWordTrackedWithThePen() throws Exception {
String extracted = extract(session -> session.pageFlow(page -> page
.addParagraph(p -> p
.text("PROFILE")
.textStyle(DocumentTextStyle.builder()
.size(18)
.letterSpacing(DocumentLetterSpacing.ofFontSize(0.3))
.build()))));

assertThat(extracted).contains("PROFILE");
assertThat(spelledOutRuns(extracted)).isEmpty();
}

@Test
void theDetectorLeavesOrdinaryProseAlone() {
// Nothing spelled out: headings, prose, and initials short enough to be
// words rather than a spelled-out run.
assertThat(spelledOutRuns("PROFILE EXPERIENCE EDUCATION")).isEmpty();
assertThat(spelledOutRuns("Nothing here is spelled out letter by letter")).isEmpty();
assertThat(spelledOutRuns("Ordinary prose with short words a b and c")).isEmpty();

// And it still fires on a genuine run buried in prose.
assertThat(spelledOutRuns("Delivery lead, a s p e c i a l case aside"))
.containsExactly("a s p e c i a l");
}

// -- helpers -----------------------------------------------------------

private static List<String> spelledOutRuns(String text) {
Matcher matcher = SPELLED_OUT.matcher(text);
List<String> runs = new ArrayList<>();
while (matcher.find()) {
runs.add(matcher.group());
}
return runs;
}

private interface Composition {
void compose(DocumentSession session);
}

private static String extract(Composition composition) throws IOException {
byte[] pdf;
try (DocumentSession session = GraphCompose.document().create()) {
composition.compose(session);
pdf = session.toPdfBytes();
}
try (PDDocument document = Loader.loadPDF(pdf)) {
// One line break is a wrapping decision, not a text-layer defect.
return new PDFTextStripper().getText(document).replaceAll("\\s*\\R\\s*", " ");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.demcha.compose.document.templates.coverletter.presets;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.coverletter.data.CoverLetterDocument;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Every shipped cover-letter preset says the words it draws.
*
* <p>A letter is read by a person and by an applicant tracking system, and the
* fields both look for — who wrote it, what they do — are exactly the ones a
* design is most tempted to letter-space.</p>
*/
class CoverLetterTextLayerGateTest {

/** Three or more single letters separated by any Unicode space separator. */
private static final Pattern SPELLED_OUT =
Pattern.compile("(?<!\\p{L})(?:\\p{L}[\\p{Zs}]){2,}\\p{L}(?!\\p{L})");

@ParameterizedTest(name = "{0}")
@MethodSource("presets")
void theLetterReadsBackAsWords(String slug, Supplier<DocumentTemplate<CoverLetterDocument>> factory)
throws Exception {
String extracted = extract(factory.get());

assertThat(spelledOutRuns(extracted))
.describedAs("%s spells something out letter by letter", slug)
.isEmpty();
// The identity an ATS reads the letter by.
assertThat(extracted)
.describedAs("%s lost the sender's name from its text layer", slug)
.contains(CoverLetterV2VisualParityTest.canonicalLetter().identity().name().full());
}

/** No preset ships without appearing above. */
@Test
void everyShippedPresetIsOnThisList() throws IOException {
Path dir = Path.of("..", "templates", "src", "main", "java", "com", "demcha",
"compose", "document", "templates", "coverletter", "presets");
List<String> shipped = new ArrayList<>();
try (var files = Files.list(dir)) {
for (Path file : files.toList()) {
String name = file.getFileName().toString();
if (name.endsWith(".java")
&& Files.readString(file).contains("public static final String ID")) {
shipped.add(name.substring(0, name.length() - ".java".length()));
}
}
}
List<String> covered = presets().map(a -> (String) a.get()[0]).toList();
assertThat(shipped)
.describedAs("a shipped cover-letter preset is not rendered by this gate")
.allSatisfy(preset -> assertThat(covered).contains(preset));
}

private static Stream<Arguments> presets() {
return Stream.of(
letter("BlueBannerLetter", BlueBannerLetter::create),
letter("BoxedSectionsLetter", BoxedSectionsLetter::create),
letter("CenteredHeadlineLetter", CenteredHeadlineLetter::create),
letter("ClassicSerifLetter", ClassicSerifLetter::create),
letter("CompactMonoLetter", CompactMonoLetter::create),
letter("EditorialBlueLetter", EditorialBlueLetter::create),
letter("EngineeringResumeLetter", EngineeringResumeLetter::create),
letter("ExecutiveLetter", ExecutiveLetter::create),
letter("MintEditorialLetter", MintEditorialLetter::create),
letter("ModernProfessionalLetter", ModernProfessionalLetter::create),
letter("MonogramSidebarLetter", MonogramSidebarLetter::create),
letter("NordicCleanLetter", NordicCleanLetter::create),
letter("PanelLetter", PanelLetter::create),
letter("SidebarPortraitLetter", SidebarPortraitLetter::create),
letter("TimelineMinimalLetter", TimelineMinimalLetter::create));
}

private static Arguments letter(String slug,
Supplier<DocumentTemplate<CoverLetterDocument>> factory) {
return Arguments.of(slug, factory);
}

private static List<String> spelledOutRuns(String text) {
Matcher matcher = SPELLED_OUT.matcher(text);
List<String> runs = new ArrayList<>();
while (matcher.find()) {
runs.add(matcher.group());
}
return runs;
}

private static String extract(DocumentTemplate<CoverLetterDocument> template) throws IOException {
byte[] pdf;
try (DocumentSession session = GraphCompose.document()
.pageSize(DocumentPageSize.A4)
.create()) {
template.compose(session, CoverLetterV2VisualParityTest.canonicalLetter());
pdf = session.toPdfBytes();
}
try (PDDocument document = Loader.loadPDF(pdf)) {
return new PDFTextStripper().getText(document).replaceAll("\\s*\\R\\s*", " ");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ private static Stream<Arguments> presets() {
* <p>Kept inline (not pulled from the examples module) so the test
* depends only on main + main-test code.</p>
*/
private static CoverLetterDocument canonicalLetter() {
static CoverLetterDocument canonicalLetter() {
return CoverLetterDocument.builder()
.identity(CvIdentity.builder()
.name("Jordan", "Rivera")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ class CvPresetTextLayerTest {
private static final List<String> PROBES =
List.of("Platform", "certification", "retired", "drafts", "fifteen");

/** Margin for presets whose design paints to the page edge. */
private static final double FULL_BLEED = 0.0;

@ParameterizedTest(name = "{0}")
@MethodSource("presets")
void theProfileTextIsInTheFileAsItWasWritten(
Expand Down Expand Up @@ -105,6 +108,9 @@ private static String renderText(DocumentTemplate<CvDocument> template, double m
.pageSize(DocumentPageSize.A4)
.margin(m, m, m, m)
.create()) {
// OrangeOps names Oswald and leaves registering it to the caller, so
// every session here carries it; the other presets do not ask for it.
OrangeOpsTestFont.register(session);
template.compose(session, probeDocument());
pdf = session.toPdfBytes();
}
Expand Down Expand Up @@ -154,7 +160,23 @@ private static Stream<Arguments> presets() {
preset("engineering_resume", EngineeringResume.RECOMMENDED_MARGIN, EngineeringResume::create),
preset("monogram_sidebar", MonogramSidebar.RECOMMENDED_MARGIN, MonogramSidebar::create),
preset("sidebar_portrait", SidebarPortrait.RECOMMENDED_MARGIN, SidebarPortrait::create),
preset("mint_editorial", MintEditorial.RECOMMENDED_MARGIN, MintEditorial::create));
preset("mint_editorial", MintEditorial.RECOMMENDED_MARGIN, MintEditorial::create),
// The promoted presets. They were absent from this list while each
// grew its own letter-by-letter tracker, which is exactly how ten
// of them shipped spelling their headings out in the text layer.
preset("charcoal_gold", CharcoalGold.RECOMMENDED_MARGIN, CharcoalGold::create),
preset("navy_sidebar", NavySidebar.RECOMMENDED_MARGIN, NavySidebar::create),
preset("professional_sidebar", ProfessionalSidebar.RECOMMENDED_MARGIN, ProfessionalSidebar::create),
preset("serif_headline", SerifHeadline.RECOMMENDED_MARGIN, SerifHeadline::create),
preset("terracotta_rail", TerracottaRail.RECOMMENDED_MARGIN, TerracottaRail::create),
// Full-bleed designs: they paint to the page edge and publish no
// recommended margin, so they are composed the way their own
// snapshot tests compose them.
preset("midnight_navy", FULL_BLEED, MidnightNavy::create),
preset("orange_ops", FULL_BLEED, OrangeOps::create),
preset("slate_orange", FULL_BLEED, SlateOrange::create),
preset("teal_pulse", FULL_BLEED, TealPulse::create),
preset("violet_grid", FULL_BLEED, VioletGrid::create));
}

private static Arguments preset(String slug, double margin,
Expand Down
Loading
Loading