feat(misc): Add font color option for page numbers; improve alignment & robustness (#4334)

# Description of Changes

**What was changed**
- **API & backend**
- Added optional `fontColor` (hex, e.g. `#FF0000`) to
`AddPageNumbersRequest` with OpenAPI docs, default `#000000`.
- Decode hex color with safe fallback to black; apply via
`setNonStrokingColor`.
- Switched multiple `switch` statements to concise switch expressions
and used `Locale.ROOT` for case operations.
- Clamped `position` to `1..9` and reworked alignment using proper font
metrics (`ascent`/`descent`) for top/middle/bottom positioning.
  - Centralized filename base extraction; reduced repeated calls.  
  - Used try-with-resources for `PDPageContentStream`.
- **UI & i18n**
  - Added `addPageNumbers.fontColor` label (en_GB).  
- Introduced `<input type="color" id="fontColor" ...>` with live
background preview in the Add Page Numbers form.

**Why the change was made**
- Enable users to render page numbers in a chosen color (not just
black).
- Produce visually correct placement by accounting for font metrics
(baseline vs. optical middle).
- Improve resilience (locale-safe parsing, bounds checking) and code
clarity.

Closes #3839


[after.pdf](https://github.com/user-attachments/files/22064425/after.pdf)

[before.pdf](https://github.com/user-attachments/files/22064426/before.pdf)


---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Ludy 2025-09-04 15:04:11 +02:00 committed by GitHub
parent a4a57cef92
commit 9a213c4bf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 97 additions and 66 deletions

View File

@ -1,8 +1,10 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@ -54,24 +56,27 @@ public class PageNumbersController {
String customText = request.getCustomText();
float fontSize = request.getFontSize();
String fontType = request.getFontType();
String fontColor = request.getFontColor();
Color color = Color.BLACK;
if (fontColor != null && !fontColor.trim().isEmpty()) {
try {
color = Color.decode(fontColor);
} catch (NumberFormatException e) {
color = Color.BLACK;
}
}
PDDocument document = pdfDocumentFactory.load(file);
float marginFactor;
switch (customMargin.toLowerCase()) {
case "small":
marginFactor = 0.02f;
break;
case "large":
marginFactor = 0.05f;
break;
case "x-large":
marginFactor = 0.075f;
break;
case "medium":
default:
marginFactor = 0.035f;
break;
}
float marginFactor =
switch (customMargin == null ? "" : customMargin.toLowerCase(Locale.ROOT)) {
case "small" -> 0.02f;
case "large" -> 0.05f;
case "x-large" -> 0.075f;
case "medium" -> 0.035f;
default -> 0.035f;
};
if (pagesToNumber == null || pagesToNumber.isEmpty()) {
pagesToNumber = "all";
@ -79,9 +84,17 @@ public class PageNumbersController {
if (customText == null || customText.isEmpty()) {
customText = "{n}";
}
final String baseFilename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
List<Integer> pagesToNumberList =
GeneralUtils.parsePageList(pagesToNumber.split(","), document.getNumberOfPages());
// Clamp position to 1..9 (1 = top-left, 9 = bottom-right)
int pos = Math.max(1, Math.min(9, position));
for (int i : pagesToNumberList) {
PDPage page = document.getPage(i);
PDRectangle pageSize = page.getMediaBox();
@ -90,70 +103,62 @@ public class PageNumbersController {
customText
.replace("{n}", String.valueOf(pageNumber))
.replace("{total}", String.valueOf(document.getNumberOfPages()))
.replace(
"{filename}",
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", ""));
.replace("{filename}", baseFilename);
PDType1Font currentFont =
switch (fontType.toLowerCase()) {
switch (fontType == null ? "" : fontType.toLowerCase(Locale.ROOT)) {
case "courier" -> new PDType1Font(Standard14Fonts.FontName.COURIER);
case "times" -> new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
default -> new PDType1Font(Standard14Fonts.FontName.HELVETICA);
};
float x, y;
// Text dimensions and font metrics
float textWidth = currentFont.getStringWidth(text) / 1000f * fontSize;
float ascent = currentFont.getFontDescriptor().getAscent() / 1000f * fontSize;
float descent = currentFont.getFontDescriptor().getDescent() / 1000f * fontSize;
if (position == 5) {
// Calculate text width and font metrics
float textWidth = currentFont.getStringWidth(text) / 1000 * fontSize;
// Derive column/row in range 1..3 (1 = left/top, 2 = center/middle, 3 = right/bottom)
int col = ((pos - 1) % 3) + 1; // 1 = left, 2 = center, 3 = right
int row = ((pos - 1) / 3) + 1; // 1 = top, 2 = middle, 3 = bottom
float ascent = currentFont.getFontDescriptor().getAscent() / 1000 * fontSize;
float descent = currentFont.getFontDescriptor().getDescent() / 1000 * fontSize;
// Anchor coordinates with margin
float leftX = pageSize.getLowerLeftX() + marginFactor * pageSize.getWidth();
float midX = pageSize.getLowerLeftX() + pageSize.getWidth() / 2f;
float rightX = pageSize.getUpperRightX() - marginFactor * pageSize.getWidth();
float centerX = pageSize.getLowerLeftX() + (pageSize.getWidth() / 2);
float centerY = pageSize.getLowerLeftY() + (pageSize.getHeight() / 2);
float botY = pageSize.getLowerLeftY() + marginFactor * pageSize.getHeight();
float midY = pageSize.getLowerLeftY() + pageSize.getHeight() / 2f;
float topY = pageSize.getUpperRightY() - marginFactor * pageSize.getHeight();
x = centerX - (textWidth / 2);
y = centerY - (ascent + descent) / 2;
} else {
int xGroup = (position - 1) % 3;
int yGroup = 2 - (position - 1) / 3;
// Horizontal alignment: left = anchor, center = centered, right = right-aligned
float x =
switch (col) {
case 1 -> leftX;
case 2 -> midX - textWidth / 2f;
default -> rightX - textWidth;
};
x =
switch (xGroup) {
case 0 ->
pageSize.getLowerLeftX()
+ marginFactor * pageSize.getWidth(); // left
case 1 ->
pageSize.getLowerLeftX() + (pageSize.getWidth() / 2); // center
default ->
pageSize.getUpperRightX()
- marginFactor * pageSize.getWidth(); // right
};
// Vertical alignment (baseline!):
// top = align text top at topY,
// middle = optical middle using ascent/descent,
// bottom = baseline at botY
float y =
switch (row) {
case 1 -> topY - ascent;
case 2 -> midY - (ascent + descent) / 2f;
default -> botY;
};
y =
switch (yGroup) {
case 0 ->
pageSize.getLowerLeftY()
+ marginFactor * pageSize.getHeight(); // bottom
case 1 ->
pageSize.getLowerLeftY() + (pageSize.getHeight() / 2); // middle
default ->
pageSize.getUpperRightY()
- marginFactor * pageSize.getHeight(); // top
};
}
PDPageContentStream contentStream =
try (PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
contentStream.beginText();
contentStream.setFont(currentFont, fontSize);
contentStream.newLineAtOffset(x, y);
contentStream.showText(text);
contentStream.endText();
contentStream.close();
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
contentStream.beginText();
contentStream.setFont(currentFont, fontSize);
contentStream.setNonStrokingColor(color);
contentStream.newLineAtOffset(x, y);
contentStream.showText(text);
contentStream.endText();
}
pageNumber++;
}

View File

@ -32,6 +32,13 @@ public class AddPageNumbersRequest extends PDFWithPageNums {
requiredMode = RequiredMode.REQUIRED)
private String fontType;
@Schema(
description = "Hex colour for page numbers (e.g. #FF0000)",
example = "#000000",
defaultValue = "#000000",
requiredMode = RequiredMode.NOT_REQUIRED)
private String fontColor;
@Schema(
description =
"Position: 1-9 representing positions on the page (1=top-left, 2=top-center,"

View File

@ -137,6 +137,7 @@ lang.yor=Yoruba
addPageNumbers.fontSize=Font Size
addPageNumbers.fontName=Font Name
addPageNumbers.fontColor=Font Colour
pdfPrompt=Select PDF(s)
multiPdfPrompt=Select PDFs (2+)
multiPdfDropPrompt=Select (or drag & drop) all PDFs you require

View File

@ -99,6 +99,24 @@
<option value="Courier">Courier New</option>
</select>
</div>
<div class="mb-3">
<label for="fontColor" th:text="#{addPageNumbers.fontColor}"></label>
<div class="form-control form-control-color" style="background-color: #000000;">
<input type="color" id="fontColor" name="fontColor" value="#000000">
</div>
<script>
let colorInput = document.getElementById("fontColor");
if (colorInput) {
let colorInputContainer = colorInput.parentElement;
if (colorInputContainer) {
colorInput.onchange = function() {
colorInputContainer.style.backgroundColor = colorInput.value;
}
colorInputContainer.style.backgroundColor = colorInput.value;
}
}
</script>
</div>
<div class="mb-3">
<label for="startingNumber" th:text="#{addPageNumbers.selectText.4}"></label>
<input type="number" class="form-control" id="startingNumber" name="startingNumber" min="1" required value="1">