Skip to content
8 changes: 4 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
* import/migrate library from one computer to another - can already be done manually, needs documentation. GUI for this will not be done.
* test docker image and create a pre-release v2.0.0_rc1
* port MacOS deployment to python (automate in CI)
* PDF generation: create reproducible working PDFs irrespective of the browser that the user is using. The PDF generation needs a browser engine that runs headless and supports JavaScript. make sure the PDFs created by the browser version used work for OID printing (ideally PNG images are not changed in the PDF)
* selenium + browser is a good option because it allows us to try several different browsers we can first check with a live browser if the PDFs generate working codes. also, we already use sodium for the end-to-end tests so the additional packaging overhead is minimal
* if selenium and browsers fail, try [playwright] (https://www.checklyhq.com/docs/learn/playwright/generating-pdfs/)
* wkhtml2pdf works for Windows but development is stale and PDFs only work in a very old version with known vulnerabilities
* ~~PDF generation: create reproducible working PDFs irrespective of the browser that the user is using. The PDF generation needs a browser engine that runs headless and supports JavaScript. make sure the PDFs created by the browser version used work for OID printing (ideally PNG images are not changed in the PDF)~~ **DONE: Now using Chromium headless for PDF generation**
* ~~selenium + browser is a good option because it allows us to try several different browsers we can first check with a live browser if the PDFs generate working codes. also, we already use sodium for the end-to-end tests so the additional packaging overhead is minimal~~
* ~~if selenium and browsers fail, try [playwright] (https://www.checklyhq.com/docs/learn/playwright/generating-pdfs/)~~
* ~~wkhtml2pdf works for Windows but development is stale and PDFs only work in a very old version with known vulnerabilities~~ **DONE: Replaced with Chromium headless**
* port Windows deployment to python (automate in CI)
* save last selected albums in the browsers local storage
5 changes: 3 additions & 2 deletions build/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ FROM python:3.12-slim AS base
# Set working directory
WORKDIR /app

# Install system dependencies (wget, unzip for downloads, ffmpeg for audio conversion, git for setuptools-scm)
# Install system dependencies (wget, unzip for downloads, ffmpeg for audio conversion, git for setuptools-scm, chromium for PDF generation)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
wget \
unzip \
xz-utils \
ca-certificates \
ffmpeg \
git && \
git \
chromium && \
rm -rf /var/lib/apt/lists/*

# Install tttool (from releases)
Expand Down
34 changes: 25 additions & 9 deletions src/assets/js/print.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,24 +280,40 @@ var changeNumberOfColumns = function($id) {
}

var savePDF = function() {
// First save the configuration
var configVars = getElementValues($('#config'));
$.post(
document.baseURI,
'action=save_pdf&data=' + encodeURIComponent(JSON.stringify({content: $('#wrap-all-print').html()})),
function(data,textStatus,jqXHR) {
if (data.success) {
setTimeout(function() { window.open('/print.pdf'); }, 10000);
notify($('#pdf-save'), '', 'Creating pdf, please wait about 10 s... (you need to allow popups to see the pdf. otherwise open "http://'+window.location.host+'/print.pdf" manually', 'bg-info',
10000);
'action=save_config&data=' + encodeURIComponent(JSON.stringify(configVars)),
function(configData, textStatus, jqXHR) {
if (configData.success) {
// Configuration saved, now generate PDF
$.post(
document.baseURI,
'action=save_pdf&data=' + encodeURIComponent(JSON.stringify({content: $('#wrap-all-print').html()})),
function(data,textStatus,jqXHR) {
if (data.success) {
setTimeout(function() { window.open('/print.pdf'); }, 10000);
notify($('#pdf-save'), '', 'Creating pdf, please wait about 10 s... (you need to allow popups to see the pdf. otherwise open "http://'+window.location.host+'/print.pdf" manually', 'bg-info',
10000);
} else {
notify($('#pdf-save'), '', jqXHR.statusText, 'bg-danger',
4000);
}
}, 'json').fail(
function() {
notify($('#pdf-save'), '', 'Connection error', 'bg-danger',
4000);
});
} else {
notify($('#pdf-save'), '', jqXHR.statusText, 'bg-danger',
notify($('#pdf-save'), '', 'Failed to save configuration: ' + configData.error, 'bg-danger',
4000);
}
}, 'json').fail(
function() {
notify($('#pdf-save'), '', 'Connection error', 'bg-danger',
notify($('#pdf-save'), '', 'Connection error while saving configuration', 'bg-danger',
4000);
});

}

$(function() {
Expand Down
6 changes: 6 additions & 0 deletions src/templates/pdf.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
<link href="/assets/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/css/fine-uploader-new.min.css" rel="stylesheet">
<link href="/assets/css/print.css" rel="stylesheet">
<style>
@page {
size: {{ page_size }};
margin: {{ page_margin }};
}
</style>
<script src="/assets/js/jquery-3.1.1.min.js"></script>
<script src="/assets/js/jquery.matchHeight-min.js"></script>
</head>
Expand Down
6 changes: 6 additions & 0 deletions src/templates/print.html
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ <h4 class="panel-title">
data-placement="left"
title="Paper size for printing. This has only been tested with A4. In case your oid codes are scaled wrongly, try adjusting this value. Valid entries are for example: A4; A4 landscape; 21cm 27.9cm; letter; 5.5in 8.5in.">
</div>
<div class="form-group">
<label for="print_page_margin">Page margin:</label> <input type="text"
name="print_page_margin" class="form-control" data-toggle="tooltip"
data-placement="left"
title="Page margin for PDF generation. Default is 0.5in. Valid entries are for example: 0.5in; 1cm; 10mm; 0.25in.">
</div>
<div id="max-tracks" class="form-group">
<label for="print_max_track_controls">Maximum number of tracks in
general controls:</label> <input type="number" name="print_max_track_controls"
Expand Down
7 changes: 6 additions & 1 deletion src/ttmp32gme/db_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ def initialize(self):
"""
INSERT OR IGNORE INTO config VALUES('host','127.0.0.1');
INSERT OR IGNORE INTO config VALUES('port','10020');
INSERT OR IGNORE INTO config VALUES('version','1.0.0');
INSERT OR IGNORE INTO config VALUES('version','2.0.0');
INSERT OR IGNORE INTO config VALUES('open_browser','TRUE');
INSERT OR IGNORE INTO config VALUES('tt_dpi','1200');
INSERT OR IGNORE INTO config VALUES('tt_code-dim',NULL);
Expand All @@ -300,6 +300,7 @@ def initialize(self):
INSERT OR IGNORE INTO config VALUES('audio_format','mp3');
INSERT OR IGNORE INTO config VALUES('print_max_track_controls','24');
INSERT OR IGNORE INTO config VALUES('print_page_size','A4');
INSERT OR IGNORE INTO config VALUES('print_page_margin','0.5in');
INSERT OR IGNORE INTO config VALUES('print_show_cover','TRUE');
INSERT OR IGNORE INTO config VALUES('print_show_album_info','TRUE');
INSERT OR IGNORE INTO config VALUES('print_show_album_controls','TRUE');
Expand Down Expand Up @@ -1182,6 +1183,10 @@ def update_db(self) -> bool:
"ALTER TABLE gme_library ADD COLUMN player_mode TEXT DEFAULT 'music';",
],
"1.0.0": ["UPDATE config SET value='1.0.0' WHERE param='version';"],
"2.0.0": [
"UPDATE config SET value='2.0.0' WHERE param='version';",
"INSERT OR IGNORE INTO config (param, value) VALUES ('print_page_margin', '0.5in');",
],
}
current_version = Version(self.get_config_value("version"))

Expand Down
83 changes: 44 additions & 39 deletions src/ttmp32gme/print_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ def create_print_layout(


def create_pdf(port: int, library_path: Optional[Path] = None) -> Optional[Path]:
"""Create PDF from print layout using wkhtmltopdf.
"""Create PDF from print layout using Chromium headless.

Args:
port: Server port number for accessing the print page via HTTP
Expand All @@ -247,48 +247,53 @@ def create_pdf(port: int, library_path: Optional[Path] = None) -> Optional[Path]
Returns:
Path to created PDF file, or None if PDF creation failed
"""
wkhtmltopdf_path = get_executable_path("wkhtmltopdf")
# Try multiple possible chromium binary names
chromium_names = ["chromium", "chromium-browser", "google-chrome", "chrome"]
chromium_path = None

if not wkhtmltopdf_path:
logger.error("Could not create pdf, wkhtmltopdf not found.")
for name in chromium_names:
chromium_path = get_executable_path(name)
if chromium_path:
break

if not chromium_path:
logger.error("Could not create pdf, chromium not found.")
return None

if library_path is None:
library_path = get_default_library_path()

pdf_file = library_path / "print.pdf"

# Chromium headless PDF printing arguments
# --headless: Run in headless mode
# --disable-gpu: Disable GPU hardware acceleration
# --no-pdf-header-footer: Disable headers and footers in PDF
# --print-to-pdf=<path>: Output to PDF file at specified path
# Note: Margins are controlled via CSS @page rules in pdf.html (0.5in all sides)
# Chromium doesn't support command-line margin parameters like wkhtmltopdf
args = [
wkhtmltopdf_path,
"-B",
"0.5in",
"-T",
"0.5in",
"-L",
"0.5in",
"-R",
"0.5in",
chromium_path,
"--headless",
"--disable-gpu",
"--no-pdf-header-footer",
f"--print-to-pdf={pdf_file}",
f"http://localhost:{port}/pdf",
str(pdf_file),
]

logger.info(f"Creating PDF: {' '.join(args)}")

try:
if platform.system() == "Windows":
# Run in background on Windows
subprocess.Popen(args)
else:
# Run in background on Unix-like systems
subprocess.Popen(args)

# Run in background
subprocess.Popen(args)
return pdf_file
except Exception as e:
logger.error(f"Could not create PDF: {e}")
return None


def format_print_button() -> str:
"""Format the print button HTML based on platform and wkhtmltopdf availability.
"""Format the print button HTML based on platform and chromium availability.

Returns:
HTML string for print/PDF button(s) appropriate for the current platform
Expand All @@ -300,22 +305,22 @@ def format_print_button() -> str:
"Save as PDF</button>"
)

wkhtmltopdf_path = get_executable_path("wkhtmltopdf")

if wkhtmltopdf_path:
try:
result = subprocess.run(
[wkhtmltopdf_path, "-V"], capture_output=True, text=True, check=True
)
if "0.13." in result.stdout:
return (
'<button type="button" class="btn btn-info" onclick="javascript:window.print()">'
"Print This Page</button> "
'<button type="button" id="pdf-save" class="btn btn-primary" '
'data-toggle="popover" title="Save as pdf. The PDF usually prints better than the webpage.">'
"Save as PDF</button>"
)
except Exception:
pass
# Try multiple possible chromium binary names
chromium_names = ["chromium", "chromium-browser", "google-chrome", "chrome"]
chromium_path = None

for name in chromium_names:
chromium_path = get_executable_path(name)
if chromium_path:
break

if chromium_path:
return (
'<button type="button" class="btn btn-info" onclick="javascript:window.print()">'
"Print This Page</button> "
'<button type="button" id="pdf-save" class="btn btn-primary" '
'data-toggle="popover" title="Save as pdf. The PDF usually prints better than the webpage.">'
"Save as PDF</button>"
)

return '<button type="button" class="btn btn-info" onclick="javascript:window.print()">Print This Page</button>'
8 changes: 7 additions & 1 deletion src/ttmp32gme/ttmp32gme.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,13 @@ def print_post():
@app.route("/pdf")
def pdf_page():
"""PDF generation page."""
return render_template("pdf.html", strippedTitle="PDF", content=print_content)
return render_template(
"pdf.html",
strippedTitle="PDF",
content=print_content,
page_size=config.get("print_page_size", "A4"),
page_margin=config.get("print_page_margin", "0.5in"),
)


@app.route("/config")
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_db_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ def test_get_config_value(self, db):
"""Test get_config_value method."""
version = db.get_config_value("version")
assert version is not None
assert version == "1.0.0"
assert version == "2.0.0"

def test_get_config_value_not_found(self, db):
"""Test get_config_value for non-existent parameter."""
Expand Down
46 changes: 30 additions & 16 deletions tests/unit/test_print_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,8 @@ class TestCreatePdf:
@patch("ttmp32gme.print_handler.get_executable_path")
@patch("ttmp32gme.print_handler.subprocess.Popen")
def test_create_pdf_success(self, mock_popen, mock_get_exec):
"""Test PDF creation with wkhtmltopdf available."""
mock_get_exec.return_value = "/usr/bin/wkhtmltopdf"
"""Test PDF creation with chromium available."""
mock_get_exec.return_value = "/usr/bin/chromium"

with tempfile.TemporaryDirectory() as tmpdir:
library_path = Path(tmpdir)
Expand All @@ -301,10 +301,14 @@ def test_create_pdf_success(self, mock_popen, mock_get_exec):
assert result is not None
assert result == library_path / "print.pdf"
assert mock_popen.called
# Verify chromium headless arguments
call_args = mock_popen.call_args[0][0]
assert "--headless" in call_args
assert "--no-pdf-header-footer" in call_args

@patch("ttmp32gme.print_handler.get_executable_path")
def test_create_pdf_no_wkhtmltopdf(self, mock_get_exec):
"""Test PDF creation when wkhtmltopdf not found."""
def test_create_pdf_no_chromium(self, mock_get_exec):
"""Test PDF creation when chromium not found."""
mock_get_exec.return_value = None

result = create_pdf(10020)
Expand All @@ -315,13 +319,29 @@ def test_create_pdf_no_wkhtmltopdf(self, mock_get_exec):
@patch("ttmp32gme.print_handler.subprocess.Popen")
def test_create_pdf_exception(self, mock_popen, mock_get_exec):
"""Test PDF creation when subprocess fails."""
mock_get_exec.return_value = "/usr/bin/wkhtmltopdf"
mock_get_exec.return_value = "/usr/bin/chromium"
mock_popen.side_effect = Exception("Test error")

result = create_pdf(10020)

assert result is None

@patch("ttmp32gme.print_handler.get_executable_path")
@patch("ttmp32gme.print_handler.subprocess.Popen")
def test_create_pdf_tries_multiple_names(self, mock_popen, mock_get_exec):
"""Test PDF creation tries multiple chromium binary names."""
# First call returns None (chromium), second returns path (chromium-browser)
mock_get_exec.side_effect = [None, "/usr/bin/chromium-browser"]

with tempfile.TemporaryDirectory() as tmpdir:
library_path = Path(tmpdir)

result = create_pdf(10020, library_path)

assert result is not None
assert result == library_path / "print.pdf"
assert mock_popen.called


class TestFormatPrintButton:
"""Test format_print_button function."""
Expand All @@ -338,8 +358,8 @@ def test_format_print_button_windows(self, mock_system):

@patch("ttmp32gme.print_handler.platform.system")
@patch("ttmp32gme.print_handler.get_executable_path")
def test_format_print_button_linux_no_wkhtmltopdf(self, mock_get_exec, mock_system):
"""Test print button on Linux without wkhtmltopdf."""
def test_format_print_button_linux_no_chromium(self, mock_get_exec, mock_system):
"""Test print button on Linux without chromium."""
mock_system.return_value = "Linux"
mock_get_exec.return_value = None

Expand All @@ -350,16 +370,10 @@ def test_format_print_button_linux_no_wkhtmltopdf(self, mock_get_exec, mock_syst

@patch("ttmp32gme.print_handler.platform.system")
@patch("ttmp32gme.print_handler.get_executable_path")
@patch("ttmp32gme.print_handler.subprocess.run")
def test_format_print_button_linux_with_old_wkhtmltopdf(
self, mock_run, mock_get_exec, mock_system
):
"""Test print button on Linux with wkhtmltopdf 0.13."""
def test_format_print_button_linux_with_chromium(self, mock_get_exec, mock_system):
"""Test print button on Linux with chromium available."""
mock_system.return_value = "Linux"
mock_get_exec.return_value = "/usr/bin/wkhtmltopdf"
mock_result = Mock()
mock_result.stdout = "wkhtmltopdf 0.13.0"
mock_run.return_value = mock_result
mock_get_exec.return_value = "/usr/bin/chromium"

result = format_print_button()

Expand Down