DocService.java
/*
* Copyright © 2026 The CTAN Team and individual authors
*
* This file is distributed under the 3-clause BSD license.
* See file LICENSE for details.
*/
package org.ctan.site.services;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
/**
* The class <code>DocService</code> analyses document files and extracts
* metadata such as title, author, and page count.
*
* <p>
* Supported formats are PDF ({@code .pdf}), Markdown ({@code .md}), and HTML
* ({@code .html}, {@code .htm}). All paths are resolved relative to the
* texarchive base directory supplied at construction time.
* </p>
*
* @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
*/
@Slf4j
public class DocService {
/**
* The class <code>DocInfo</code> contains the transport object for the
* document meta data.
*/
@AllArgsConstructor
@Getter
public static class DocInfo {
String title;
String author;
Integer pages;
}
/**
* The field <code>NO_INFO</code> contains the sentinel value returned when
* no metadata could be extracted from a document. All fields are
* {@code null}.
*/
private static final DocInfo NO_INFO = new DocInfo(null, null, null);
private static final Pattern HTML_TITLE_PATTERN = Pattern
.compile("^.*<title>(.*)</title>.*");
private static final Pattern YAML_PATTERN = Pattern
.compile("^(title|author)\\s*:\\s*(.+)$");
/**
* Strips a single layer of surrounding single or double quotes from a YAML
* scalar value, if present.
*
* @param value the raw scalar string; must not be {@code null}
* @return the unquoted string, or the original value if no quotes were
* found
*/
private static String unquote(String value) {
if (value.length() >= 2) {
char first = value.charAt(0);
char last = value.charAt(value.length() - 1);
if ((first == '"' && last == '"')
|| (first == '\'' && last == '\'')) {
return value.substring(1, value.length() - 1);
}
}
return value;
}
/**
* The base directory against which relative document paths are resolved.
*/
private File base;
/**
* This is the constructor for <code>DocService</code>.
*
* @param base the base directory
*/
public DocService(File base) {
this.base = base;
}
/**
* The method <code>analyse</code> provides means to TODO gene.
*
* @param file the input file
* @return the info instance or {@link NO_INFO}
*/
public DocInfo analyse(File file) {
if (!file.canRead()) {
return NO_INFO;
}
String name = file.getName();
int i = name.lastIndexOf('.');
if (i <= 0) {
return NO_INFO;
}
return switch (name.substring(i + 1).toLowerCase()) {
case "html", "htm" -> analyseHtml(file);
case "md" -> analyseMarkdown(file);
case "pdf" -> analysePdf(file);
case "ps" -> analysePs(file);
default -> NO_INFO;
};
}
/**
* Analyses the document at the given path and returns its metadata.
*
* <p>
* The prefixes {@code http://mirrors.ctan.org/} and
* {@code http://mirrors.ctan.org/} of the path are stripped.
* </p>
*
* <p>
* The file type is determined by the path suffix. If the file cannot be
* read, or the suffix is not recognised, {@link #NO_INFO} is returned.
* </p>
*
* @param path the path to the document, relative to the base directory;
* must not be {@code null}
* @return a {@link DocInfo} with the extracted metadata; never {@code null}
*/
public DocInfo analyse(String path) {
if (path.startsWith("http://mirrors.ctan.org/")) {
path = path.substring(24);
} else if (path.startsWith("https://mirrors.ctan.org/")) {
path = path.substring(25);
}
return analyse(new File(base, path));
}
/**
* Extracts metadata from a HTML file.
*
* <p>
* Scans the file line by line for a {@code <title>} element and returns its
* text content as the document title. Author and page count are not
* extracted from HTML and will be {@code null}.
* </p>
*
* @param file the HTML file to analyse; must not be {@code null}
* @return a {@link DocInfo} with the title, or {@link #NO_INFO} if no
* {@code <title>} element was found or an I/O error occurred
*/
public DocInfo analyseHtml(File file) {
try (BufferedReader reader = new BufferedReader(
new FileReader(file, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
Matcher matcher = HTML_TITLE_PATTERN.matcher(line.strip());
if (matcher.matches()) {
return new DocInfo(matcher.group(1).strip(), null, null);
}
}
} catch (IOException e) {
log.error("Reading failed for " + file.getPath());
}
return NO_INFO;
}
/**
* Extracts metadata from a Markdown file.
*
* <p>
* If the file begins with a YAML front-matter block (delimited by
* {@code ---}), the {@code title} and {@code author} keys are read from it.
* Otherwise the first ATX level-1 heading ({@code # …}) is used as the
* title. Author and page count are not available outside of front matter
* and will be {@code null}.
* </p>
*
* @param file the Markdown file to analyse; must not be {@code null}
* @return a {@link DocInfo} with the extracted metadata, or
* {@link #NO_INFO} if nothing could be extracted or an I/O error
* occurred
*/
public DocInfo analyseMarkdown(File file) {
try (BufferedReader reader = new BufferedReader(
new FileReader(file, StandardCharsets.UTF_8))) {
String line = reader.readLine();
if ("---".equals(line)) {
DocInfo info = analyseMarkdownYamlHeader(reader);
if (info != null) {
return info;
}
}
if (line == null) {
return NO_INFO;
}
do {
if (line.startsWith("# ")) {
line = line.substring(2).replaceAll(" *#$", "");
return new DocInfo(line, null, null);
}
} while ((line = reader.readLine()) != null);
} catch (IOException e) {
log.error("Reading failed for " + file.getPath());
}
return NO_INFO;
}
/**
* Extracts metadata from a PDF document using Apache PDFBox.
*
* <p>
* Reads title, author, and page count from the PDF document information
* dictionary. Any I/O failure is logged at error level and {@code null} is
* returned.
* </p>
*
* @param file the PDF file to analyse; must not be {@code null}
* @return a {@link DocInfo} with the extracted metadata
*/
public DocInfo analysePdf(File file) {
try {
PDDocument doc = Loader.loadPDF(file);
PDDocumentInformation info = doc.getDocumentInformation();
return new DocInfo(info.getTitle(),
info.getAuthor(),
doc.getNumberOfPages());
} catch (IOException e) {
log.error("Reading failed for " + file.getPath());
}
return NO_INFO;
}
/**
* Extracts metadata from a Postscript file.
*
* <p>
* If the file begins with a comments block (lines starting with
* {@code %%}), the {@code Title} key is read from it. Author and possibly
* page count are not available in the comments block and will be
* {@code null} then.
* </p>
*
* @param file the Postscript file to analyse; must not be {@code null}
* @return a {@link DocInfo} with the extracted metadata, or
* {@link #NO_INFO} if nothing could be extracted or an I/O error
* occurred
*/
public DocInfo analysePs(File file) {
try (BufferedReader reader = new BufferedReader(
new FileReader(file, StandardCharsets.UTF_8))) {
String line = reader.readLine();
if (!"%!PS-Adobe-2.0".equals(line)) {
return NO_INFO;
}
String title = null;
Integer pages = null;
while ((line = reader.readLine()) != null) {
if (!line.startsWith("%%")) {
return new DocInfo(title, null, pages);
} else if (line.startsWith("%%Title: ")) {
title = line.substring(9).strip();
} else if (line.startsWith("%%Pages: ")) {
try {
pages = Integer.valueOf(line.substring(9).strip());
} catch (NumberFormatException e) {
// ignore
}
}
}
} catch (IOException e) {
log.error("Reading failed for " + file.getPath());
}
return NO_INFO;
}
/**
* Parses the YAML front-matter block of a Markdown file.
*
* <p>
* The reader must be positioned <em>after</em> the opening {@code ---}
* line. The method reads until a closing {@code ---} or {@code ...} line
* and extracts {@code title} and {@code author} scalar values.
* </p>
*
* @param reader the reader positioned just after the opening delimiter;
* must not be {@code null}
* @return a {@link DocInfo} with the extracted values (fields may be
* {@code null} if the keys were absent), or {@code null} if the
* expected closing delimiter was not found as the very next line
* @throws IOException if an I/O error occurs while reading
*/
private DocInfo analyseMarkdownYamlHeader(BufferedReader reader)
throws IOException {
String title = null;
String author = null;
String line;
while ((line = reader.readLine()) != null) {
if (line.equals("---") || line.equals("...")) {
break;
}
Matcher matcher = YAML_PATTERN.matcher(line.strip());
if (matcher.matches()) {
String value = unquote(matcher.group(2).strip());
if (matcher.group(1).equals("title")) {
title = value;
} else {
author = value;
}
}
}
return new DocInfo(title, author, null);
}
}