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

View File

@ -32,6 +32,13 @@ public class AddPageNumbersRequest extends PDFWithPageNums {
requiredMode = RequiredMode.REQUIRED) requiredMode = RequiredMode.REQUIRED)
private String fontType; 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( @Schema(
description = description =
"Position: 1-9 representing positions on the page (1=top-left, 2=top-center," "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.fontSize=Font Size
addPageNumbers.fontName=Font Name addPageNumbers.fontName=Font Name
addPageNumbers.fontColor=Font Colour
pdfPrompt=Select PDF(s) pdfPrompt=Select PDF(s)
multiPdfPrompt=Select PDFs (2+) multiPdfPrompt=Select PDFs (2+)
multiPdfDropPrompt=Select (or drag & drop) all PDFs you require multiPdfDropPrompt=Select (or drag & drop) all PDFs you require

View File

@ -99,6 +99,24 @@
<option value="Courier">Courier New</option> <option value="Courier">Courier New</option>
</select> </select>
</div> </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"> <div class="mb-3">
<label for="startingNumber" th:text="#{addPageNumbers.selectText.4}"></label> <label for="startingNumber" th:text="#{addPageNumbers.selectText.4}"></label>
<input type="number" class="form-control" id="startingNumber" name="startingNumber" min="1" required value="1"> <input type="number" class="form-control" id="startingNumber" name="startingNumber" min="1" required value="1">