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);
    }
}