@@ -185,6 +185,32 @@ private void addSectionBanner(SectionBuilder section, String title) {
185185 private void addModuleBody (SectionBuilder section , CvModule module ) {
186186 section .spacing (4 )
187187 .padding (new DocumentInsets (4 , 4 , 0 , 4 ));
188+ // Projects render with a dedicated two-line layout — bold
189+ // project name (with optional tech stack in parens) on the
190+ // first line behind a bullet, then a hanging-indented
191+ // description below — instead of the flat single-line bullet
192+ // used for general bullet lists. Matches the canonical CV
193+ // visual where "what the project is" stands apart from "what
194+ // it did". Honours both shapes the data layer ships: a
195+ // {@link BulletListBlock} with "**Name (tech)** - Description"
196+ // strings and an {@link IndentedBlock} with separate title /
197+ // body fields.
198+ if (isProjectsModule (module .title ())) {
199+ if (module .body () instanceof BulletListBlock projects ) {
200+ for (String item : projects .items ()) {
201+ renderProjectItem (section , parseProjectItem (safe (item ).trim ()));
202+ }
203+ return ;
204+ }
205+ if (module .body () instanceof IndentedBlock indented ) {
206+ for (IndentedBlock .Item item : indented .items ()) {
207+ renderProjectItem (section ,
208+ new ProjectParts (safe (item .title ()).trim (),
209+ safe (item .body ()).trim ()));
210+ }
211+ return ;
212+ }
213+ }
188214 renderBody (section , module .body ());
189215 }
190216
@@ -266,6 +292,54 @@ private void renderBulletItem(SectionBuilder section, String rawLine) {
266292 .rich (rich -> appendMarkdown (rich , text , base )));
267293 }
268294
295+ /**
296+ * Renders one project entry as two stacked paragraphs:
297+ *
298+ * <pre>
299+ * • <b>Name</b> (tech stack)
300+ * Description text wrapped under the title, hanging-indented
301+ * so it lines up with the project name (not the bullet).
302+ * </pre>
303+ *
304+ * <p>Input format: {@code "**Name (tech)** - Description"}.
305+ * Both halves are optional — a project without a description
306+ * renders only the title line; a project without bold markers
307+ * around the name is treated as plain title text.</p>
308+ */
309+ private void renderProjectItem (SectionBuilder section , ProjectParts parts ) {
310+ if (parts .name ().isBlank () && parts .description ().isBlank ()) {
311+ return ;
312+ }
313+ DocumentTextStyle base = style (BODY_FONT , 8.6 ,
314+ DocumentTextDecoration .DEFAULT , INK );
315+ DocumentTextStyle nameStyle = style (BODY_FONT , 8.6 ,
316+ DocumentTextDecoration .BOLD , INK );
317+
318+ section .addParagraph (paragraph -> paragraph
319+ .textStyle (base )
320+ .lineSpacing (1.4 )
321+ .align (TextAlign .LEFT )
322+ .margin (DocumentInsets .top (2 ))
323+ .bulletOffset ("• " )
324+ .indentStrategy (DocumentTextIndent .ALL_LINES )
325+ .rich (rich -> appendMarkdown (rich , parts .name (), nameStyle )));
326+
327+ if (parts .description ().isBlank ()) {
328+ return ;
329+ }
330+ // Two-space prefix matches the bullet+space width inside the
331+ // hanging-indent computation, so the description's first
332+ // glyph sits under the project name rather than the bullet.
333+ section .addParagraph (paragraph -> paragraph
334+ .textStyle (base )
335+ .lineSpacing (1.4 )
336+ .align (TextAlign .LEFT )
337+ .margin (DocumentInsets .zero ())
338+ .bulletOffset (" " )
339+ .indentStrategy (DocumentTextIndent .ALL_LINES )
340+ .rich (rich -> appendMarkdown (rich , parts .description (), base )));
341+ }
342+
269343 private void renderWorkEntry (SectionBuilder section , WorkEntry entry ) {
270344 DocumentTextStyle positionStyle = style (BODY_FONT , 9.2 ,
271345 DocumentTextDecoration .BOLD , INK );
@@ -448,6 +522,30 @@ private static String stripBasicMarkdown(String value) {
448522 .replace ("_" , "" );
449523 }
450524
525+ private static boolean isProjectsModule (String title ) {
526+ if (title == null ) {
527+ return false ;
528+ }
529+ String normalized = title .toLowerCase (Locale .ROOT ).trim ();
530+ return normalized .equals ("projects" ) || normalized .startsWith ("projects " );
531+ }
532+
533+ private static ProjectParts parseProjectItem (String item ) {
534+ // Split on " - " (space-hyphen-space, mirroring WorkEntry parsing)
535+ // so an em-dash or hyphen inside the description is not eaten.
536+ // Falls back to "title only" when no separator is present.
537+ int sepIndex = item .indexOf (" - " );
538+ if (sepIndex <= 0 ) {
539+ return new ProjectParts (item .trim (), "" );
540+ }
541+ String name = item .substring (0 , sepIndex ).trim ();
542+ String description = item .substring (sepIndex + 3 ).trim ();
543+ return new ProjectParts (name , description );
544+ }
545+
546+ private record ProjectParts (String name , String description ) {
547+ }
548+
451549 private static String spacedUpper (String value ) {
452550 String upper = safe (value ).toUpperCase (Locale .ROOT );
453551 StringBuilder builder = new StringBuilder ();
0 commit comments