Compare commits
11 Commits
dc8ab1a0a7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 25afbf036e | |||
| 5ac61941c6 | |||
|
|
a17d821b0e | ||
| 1cc464fa9b | |||
| 4c4e79e384 | |||
| ae57353e8f | |||
| fdb4840912 | |||
| 6f4ae177da | |||
| 9b9648d033 | |||
| 4ffc138909 | |||
|
|
08354c8905 |
55
.gitea/workflows/build-pdf.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Build PDF
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
pdf:
|
||||
name: make pdf
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go (from go.mod)
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
cache: true
|
||||
|
||||
- name: Install Chrome and tools (no snap)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=; fi
|
||||
$SUDO apt-get update
|
||||
# Base tools and fonts
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y --no-install-recommends \
|
||||
ca-certificates gnupg make fonts-liberation
|
||||
|
||||
# Add Google's official Chrome APT repo (avoids Ubuntu's snap-only chromium)
|
||||
$SUDO install -d -m 0755 /etc/apt/keyrings
|
||||
curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | $SUDO gpg --dearmor -o /etc/apt/keyrings/google-linux-signing-keyring.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-linux-signing-keyring.gpg] https://dl.google.com/linux/chrome/deb/ stable main" | \
|
||||
$SUDO tee /etc/apt/sources.list.d/google-chrome.list >/dev/null
|
||||
$SUDO apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y --no-install-recommends google-chrome-stable
|
||||
|
||||
# Clean up apt lists to keep image lean
|
||||
$SUDO rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Build PDF
|
||||
env:
|
||||
# Ensure our tools pick Chrome first if multiple are present
|
||||
CHROME_PATH: /usr/bin/google-chrome-stable
|
||||
run: make pdf
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: output-pdf
|
||||
path: _dist/output.pdf
|
||||
if-no-files-found: error
|
||||
7
Makefile
@@ -27,6 +27,8 @@ serve: deps
|
||||
|
||||
PDF := $(OUT_DIR)/output.pdf
|
||||
PDF_2UP := $(OUT_DIR)/output-2up.pdf
|
||||
PDF_IMAGE_DPI ?= 144
|
||||
PDF_JPEG_QUALITY ?= 75
|
||||
|
||||
.PHONY: deps
|
||||
deps:
|
||||
@@ -35,13 +37,12 @@ deps:
|
||||
.PHONY: pdf
|
||||
pdf: build deps
|
||||
@echo "Generating PDF by rendering each page and merging..."
|
||||
@$(GO) run ./cmd/pdfbook --pages $(OUT_DIR) --order pages.yaml --out $(PDF)
|
||||
@$(GO) run ./cmd/pdfbook --pages $(OUT_DIR) --order pages.yaml --out $(PDF) --image-dpi $(PDF_IMAGE_DPI) --jpeg-quality $(PDF_JPEG_QUALITY)
|
||||
|
||||
.PHONY: pdf-2up
|
||||
pdf-2up: build deps
|
||||
@echo "Generating 2-up PDF with headless Chrome..."
|
||||
@$(GO) run ./cmd/pdf --in $(OUT_DIR)/print_2up.html --out $(PDF_2UP) --w 11 --h 8.5
|
||||
@$(GO) run ./cmd/pdf --in $(OUT_DIR)/print_2up.html --out $(PDF_2UP) --w 11 --h 8.5 --image-dpi $(PDF_IMAGE_DPI) --jpeg-quality $(PDF_JPEG_QUALITY)
|
||||
|
||||
clean:
|
||||
rm -rf $(OUT_DIR) index.html
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Heading Now";
|
||||
src: url("../font/HeadingNow-95Medium.otf");
|
||||
}
|
||||
|
||||
/* Base */
|
||||
html, body {
|
||||
height: 100%;
|
||||
@@ -20,6 +25,39 @@ html, body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.interlude {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
padding: 4rem;
|
||||
z-index: 0; /* ensure stacking context */
|
||||
}
|
||||
|
||||
.interlude::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: grayscale(20%) brightness(0.7);
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
.interlude::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/*background: rgba(128, 0, 128, 0.3); /* purple tint */ */
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.interlude-caption {
|
||||
color: rgba(225, 225, 225, 1.0);
|
||||
font-family: "Heading Now";
|
||||
line-height: 2em;
|
||||
rotate: 5deg;
|
||||
}
|
||||
|
||||
/* Use border-box sizing everywhere to keep dimensions predictable */
|
||||
html { box-sizing: border-box; }
|
||||
*, *::before, *::after { box-sizing: inherit; }
|
||||
|
||||
BIN
assets/img/broken-screen.jpg
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
assets/img/circuit-board.jpg
Normal file
|
After Width: | Height: | Size: 5.9 MiB |
BIN
assets/img/computer_2_cool-3.png
Normal file
|
After Width: | Height: | Size: 687 B |
BIN
assets/img/crashman/crashman-1.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
assets/img/crashman/crashman-2.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
assets/img/crashman/crashman-3.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
assets/img/crashman/crashman-bg.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
assets/img/free-dirt.jpg
Normal file
|
After Width: | Height: | Size: 5.8 MiB |
BIN
assets/img/javaguy.jpg
Normal file
|
After Width: | Height: | Size: 993 KiB |
BIN
assets/img/mr-nickel.jpg
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
assets/img/windows-shirt.jpg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"smartbar/internal/config"
|
||||
"smartbar/internal/pdfassets"
|
||||
|
||||
"github.com/chromedp/cdproto/emulation"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
@@ -38,15 +39,19 @@ func findChromeExec() (string, error) {
|
||||
|
||||
func main() {
|
||||
var (
|
||||
input string
|
||||
output string
|
||||
width float64
|
||||
height float64
|
||||
input string
|
||||
output string
|
||||
width float64
|
||||
height float64
|
||||
imageDPI int
|
||||
jpegQuality int
|
||||
)
|
||||
flag.StringVar(&input, "in", "", "input HTML file path (required)")
|
||||
flag.StringVar(&output, "out", "", "output PDF path (required)")
|
||||
flag.Float64Var(&width, "w", config.PageWidthIn, "page width in inches")
|
||||
flag.Float64Var(&height, "h", config.PageHeightIn, "page height in inches")
|
||||
flag.IntVar(&imageDPI, "image-dpi", 144, "downsample image assets to this PDF pixel density; 0 disables")
|
||||
flag.IntVar(&jpegQuality, "jpeg-quality", 75, "JPEG quality for PDF image assets")
|
||||
flag.Parse()
|
||||
|
||||
if input == "" || output == "" {
|
||||
@@ -54,6 +59,32 @@ func main() {
|
||||
}
|
||||
|
||||
absInput, _ := filepath.Abs(input)
|
||||
inputRoot := filepath.Dir(absInput)
|
||||
inputRel := filepath.Base(absInput)
|
||||
imageOpts := pdfassets.OptionsForPage(width, height, imageDPI, jpegQuality)
|
||||
prepared, err := pdfassets.PrepareTree(inputRoot, imageOpts)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := prepared.Cleanup(); err != nil {
|
||||
log.Printf("warning: cleanup optimized PDF assets: %v", err)
|
||||
}
|
||||
}()
|
||||
if prepared.Stats.ImageFiles > 0 {
|
||||
fmt.Printf(
|
||||
"Optimized PDF images: %d/%d files, %s -> %s (max %dx%d, JPEG quality %d)\n",
|
||||
prepared.Stats.OptimizedImages,
|
||||
prepared.Stats.ImageFiles,
|
||||
pdfassets.FormatBytes(prepared.Stats.OriginalBytes),
|
||||
pdfassets.FormatBytes(prepared.Stats.OutputBytes),
|
||||
imageOpts.MaxWidth,
|
||||
imageOpts.MaxHeight,
|
||||
imageOpts.JPEGQuality,
|
||||
)
|
||||
}
|
||||
|
||||
absInput = filepath.Join(prepared.Root, inputRel)
|
||||
url := "file://" + absInput
|
||||
|
||||
execPath, err := findChromeExec()
|
||||
|
||||
@@ -1,158 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"smartbar/internal/config"
|
||||
"smartbar/internal/config"
|
||||
"smartbar/internal/pdfassets"
|
||||
|
||||
"github.com/chromedp/cdproto/emulation"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
pdfapi "github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
"github.com/chromedp/cdproto/emulation"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
pdfapi "github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
)
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseYAMLListOfStrings(src string) ([]string, error) {
|
||||
var out []string
|
||||
for _, line := range strings.Split(src, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "-") {
|
||||
item := strings.TrimSpace(strings.TrimPrefix(line, "-"))
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid yaml line (expect '- item'): %s", line)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
var out []string
|
||||
for _, line := range strings.Split(src, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "-") {
|
||||
item := strings.TrimSpace(strings.TrimPrefix(line, "-"))
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid yaml line (expect '- item'): %s", line)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func findChromeExec() (string, error) {
|
||||
if v := os.Getenv("CHROME_PATH"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
candidates := []string{"google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "chrome"}
|
||||
for _, name := range candidates {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
// macOS common app bundle paths
|
||||
macPaths := []string{
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
}
|
||||
for _, p := range macPaths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no Chrome/Chromium executable found; set CHROME_PATH or install chromium/google-chrome")
|
||||
if v := os.Getenv("CHROME_PATH"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
candidates := []string{"google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "chrome"}
|
||||
for _, name := range candidates {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
// macOS common app bundle paths
|
||||
macPaths := []string{
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
}
|
||||
for _, p := range macPaths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no Chrome/Chromium executable found; set CHROME_PATH or install chromium/google-chrome")
|
||||
}
|
||||
|
||||
|
||||
func main() {
|
||||
var (
|
||||
pagesDir string
|
||||
orderPath string
|
||||
outPDF string
|
||||
width float64
|
||||
height float64
|
||||
)
|
||||
flag.StringVar(&pagesDir, "pages", "_dist", "path to compiled pages directory (required)")
|
||||
flag.StringVar(&orderPath, "order", "pages.yaml", "path to YAML file listing page order (required)")
|
||||
flag.StringVar(&outPDF, "out", "_dist/output.pdf", "output PDF path (required)")
|
||||
flag.Float64Var(&width, "w", config.PageWidthIn, "page width in inches")
|
||||
flag.Float64Var(&height, "h", config.PageHeightIn, "page height in inches")
|
||||
flag.Parse()
|
||||
var (
|
||||
pagesDir string
|
||||
orderPath string
|
||||
outPDF string
|
||||
width float64
|
||||
height float64
|
||||
imageDPI int
|
||||
jpegQuality int
|
||||
)
|
||||
flag.StringVar(&pagesDir, "pages", "_dist", "path to compiled pages directory (required)")
|
||||
flag.StringVar(&orderPath, "order", "pages.yaml", "path to YAML file listing page order (required)")
|
||||
flag.StringVar(&outPDF, "out", "_dist/output.pdf", "output PDF path (required)")
|
||||
flag.Float64Var(&width, "w", config.PageWidthIn, "page width in inches")
|
||||
flag.Float64Var(&height, "h", config.PageHeightIn, "page height in inches")
|
||||
flag.IntVar(&imageDPI, "image-dpi", 144, "downsample image assets to this PDF pixel density; 0 disables")
|
||||
flag.IntVar(&jpegQuality, "jpeg-quality", 75, "JPEG quality for PDF image assets")
|
||||
flag.Parse()
|
||||
|
||||
if pagesDir == "" || orderPath == "" || outPDF == "" {
|
||||
log.Fatal("--pages, --order and --out are required")
|
||||
}
|
||||
if pagesDir == "" || orderPath == "" || outPDF == "" {
|
||||
log.Fatal("--pages, --order and --out are required")
|
||||
}
|
||||
|
||||
// Load order
|
||||
data, err := os.ReadFile(orderPath)
|
||||
must(err)
|
||||
|
||||
ordered, err := parseYAMLListOfStrings(string(data))
|
||||
must(err)
|
||||
// Load order
|
||||
data, err := os.ReadFile(orderPath)
|
||||
must(err)
|
||||
|
||||
// Resolve Chrome
|
||||
chromeExec, err := findChromeExec()
|
||||
must(err)
|
||||
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.ExecPath(chromeExec), chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", true), chromedp.Flag("hide-scrollbars", true))
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
ordered, err := parseYAMLListOfStrings(string(data))
|
||||
must(err)
|
||||
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
defer cancel()
|
||||
imageOpts := pdfassets.OptionsForPage(width, height, imageDPI, jpegQuality)
|
||||
prepared, err := pdfassets.PrepareTree(pagesDir, imageOpts)
|
||||
must(err)
|
||||
defer func() {
|
||||
if err := prepared.Cleanup(); err != nil {
|
||||
log.Printf("warning: cleanup optimized PDF assets: %v", err)
|
||||
}
|
||||
}()
|
||||
pagesDir = prepared.Root
|
||||
if prepared.Stats.ImageFiles > 0 {
|
||||
fmt.Printf(
|
||||
"Optimized PDF images: %d/%d files, %s -> %s (max %dx%d, JPEG quality %d)\n",
|
||||
prepared.Stats.OptimizedImages,
|
||||
prepared.Stats.ImageFiles,
|
||||
pdfassets.FormatBytes(prepared.Stats.OriginalBytes),
|
||||
pdfassets.FormatBytes(prepared.Stats.OutputBytes),
|
||||
imageOpts.MaxWidth,
|
||||
imageOpts.MaxHeight,
|
||||
imageOpts.JPEGQuality,
|
||||
)
|
||||
}
|
||||
|
||||
// Temp dir for individual PDFs
|
||||
tmpDir, err := os.MkdirTemp("", "smartbar_pdfs_*")
|
||||
must(err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
// Resolve Chrome
|
||||
chromeExec, err := findChromeExec()
|
||||
must(err)
|
||||
|
||||
var partPDFs []string
|
||||
for _, rel := range ordered {
|
||||
abs := filepath.Join(pagesDir, rel)
|
||||
if _, err := os.Stat(abs); err != nil {
|
||||
log.Printf("skip missing page: %s", rel)
|
||||
continue
|
||||
}
|
||||
absHTML, _ := filepath.Abs(abs)
|
||||
url := "file://" + absHTML
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.ExecPath(chromeExec), chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", true), chromedp.Flag("hide-scrollbars", true))
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
|
||||
outPath := filepath.Join(tmpDir, strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)) + ".pdf")
|
||||
// Render one page PDF via printToPDF
|
||||
err := chromedp.Run(ctx, chromedp.Tasks{
|
||||
chromedp.Navigate(url),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if err := emulation.SetEmulatedMedia().WithMedia("print").Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
params := page.PrintToPDF().
|
||||
WithPaperWidth(width).
|
||||
WithPaperHeight(height).
|
||||
WithMarginTop(0).WithMarginBottom(0).WithMarginLeft(0).WithMarginRight(0).
|
||||
WithPrintBackground(true)
|
||||
buf, _, err := params.Do(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outPath, buf, 0o644)
|
||||
}),
|
||||
})
|
||||
must(err)
|
||||
partPDFs = append(partPDFs, outPath)
|
||||
}
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
defer cancel()
|
||||
|
||||
if len(partPDFs) == 0 {
|
||||
log.Fatal("no pages produced; check order file")
|
||||
}
|
||||
// Temp dir for individual PDFs
|
||||
tmpDir, err := os.MkdirTemp("", "smartbar_pdfs_*")
|
||||
must(err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Merge PDFs into single output
|
||||
must(os.MkdirAll(filepath.Dir(outPDF), 0o755))
|
||||
var partPDFs []string
|
||||
for _, rel := range ordered {
|
||||
abs := filepath.Join(pagesDir, rel)
|
||||
if _, err := os.Stat(abs); err != nil {
|
||||
log.Printf("skip missing page: %s", rel)
|
||||
continue
|
||||
}
|
||||
absHTML, _ := filepath.Abs(abs)
|
||||
url := "file://" + absHTML
|
||||
|
||||
// pdfcpu expects []string in order and writes outPDF
|
||||
must(pdfapi.MergeCreateFile(partPDFs, outPDF, false, nil))
|
||||
fmt.Printf("Wrote %s (%d pages)\n", outPDF, len(partPDFs))
|
||||
outPath := filepath.Join(tmpDir, strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel))+".pdf")
|
||||
// Render one page PDF via printToPDF
|
||||
err := chromedp.Run(ctx, chromedp.Tasks{
|
||||
chromedp.Navigate(url),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if err := emulation.SetEmulatedMedia().WithMedia("print").Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
params := page.PrintToPDF().
|
||||
WithPaperWidth(width).
|
||||
WithPaperHeight(height).
|
||||
WithMarginTop(0).WithMarginBottom(0).WithMarginLeft(0).WithMarginRight(0).
|
||||
WithPrintBackground(true)
|
||||
buf, _, err := params.Do(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outPath, buf, 0o644)
|
||||
}),
|
||||
})
|
||||
must(err)
|
||||
partPDFs = append(partPDFs, outPath)
|
||||
}
|
||||
|
||||
if len(partPDFs) == 0 {
|
||||
log.Fatal("no pages produced; check order file")
|
||||
}
|
||||
|
||||
// Merge PDFs into single output
|
||||
must(os.MkdirAll(filepath.Dir(outPDF), 0o755))
|
||||
|
||||
// pdfcpu expects []string in order and writes outPDF
|
||||
must(pdfapi.MergeCreateFile(partPDFs, outPDF, false, nil))
|
||||
fmt.Printf("Wrote %s (%d pages)\n", outPDF, len(partPDFs))
|
||||
}
|
||||
|
||||
|
||||
|
||||
2
go.mod
@@ -9,6 +9,7 @@ require (
|
||||
github.com/chromedp/chromedp v0.9.5
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/pdfcpu/pdfcpu v0.11.0
|
||||
golang.org/x/image v0.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -25,7 +26,6 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/image v0.27.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
|
||||
470
internal/pdfassets/pdfassets.go
Normal file
@@ -0,0 +1,470 @@
|
||||
package pdfassets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
stddraw "image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
MaxWidth int
|
||||
MaxHeight int
|
||||
JPEGQuality int
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
ImageFiles int
|
||||
OptimizedImages int
|
||||
OriginalBytes int64
|
||||
OutputBytes int64
|
||||
}
|
||||
|
||||
type PreparedTree struct {
|
||||
Root string
|
||||
Stats Stats
|
||||
Cleanup func() error
|
||||
}
|
||||
|
||||
func OptionsForPage(widthIn, heightIn float64, imageDPI, jpegQuality int) Options {
|
||||
if imageDPI <= 0 {
|
||||
return Options{JPEGQuality: clampJPEGQuality(jpegQuality)}
|
||||
}
|
||||
return Options{
|
||||
MaxWidth: max(1, int(math.Ceil(widthIn*float64(imageDPI)))),
|
||||
MaxHeight: max(1, int(math.Ceil(heightIn*float64(imageDPI)))),
|
||||
JPEGQuality: clampJPEGQuality(jpegQuality),
|
||||
}
|
||||
}
|
||||
|
||||
func (o Options) Enabled() bool {
|
||||
return o.MaxWidth > 0 && o.MaxHeight > 0
|
||||
}
|
||||
|
||||
func PrepareTree(srcRoot string, opts Options) (PreparedTree, error) {
|
||||
if !opts.Enabled() {
|
||||
return PreparedTree{
|
||||
Root: srcRoot,
|
||||
Cleanup: func() error { return nil },
|
||||
}, nil
|
||||
}
|
||||
|
||||
absRoot, err := filepath.Abs(srcRoot)
|
||||
if err != nil {
|
||||
return PreparedTree{}, err
|
||||
}
|
||||
|
||||
tmpRoot, err := os.MkdirTemp("", "smartbar_pdf_assets_*")
|
||||
if err != nil {
|
||||
return PreparedTree{}, err
|
||||
}
|
||||
|
||||
prepared := PreparedTree{
|
||||
Root: tmpRoot,
|
||||
Cleanup: func() error { return os.RemoveAll(tmpRoot) },
|
||||
}
|
||||
|
||||
err = filepath.WalkDir(absRoot, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(absRoot, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
dst := filepath.Join(tmpRoot, rel)
|
||||
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(dst, 0o755)
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return copySymlink(path, dst)
|
||||
}
|
||||
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.EqualFold(filepath.Ext(path), ".pdf") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isSupportedImage(path) {
|
||||
stats, err := optimizeImageFile(path, dst, info.Mode().Perm(), opts)
|
||||
if err == nil {
|
||||
prepared.Stats.ImageFiles++
|
||||
prepared.Stats.OriginalBytes += stats.originalBytes
|
||||
prepared.Stats.OutputBytes += stats.outputBytes
|
||||
if stats.optimized {
|
||||
prepared.Stats.OptimizedImages++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return copyFile(path, dst, info.Mode().Perm())
|
||||
})
|
||||
if err != nil {
|
||||
_ = prepared.Cleanup()
|
||||
return PreparedTree{}, err
|
||||
}
|
||||
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
type imageFileStats struct {
|
||||
originalBytes int64
|
||||
outputBytes int64
|
||||
optimized bool
|
||||
}
|
||||
|
||||
func optimizeImageFile(src, dst string, mode fs.FileMode, opts Options) (imageFileStats, error) {
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return imageFileStats{}, err
|
||||
}
|
||||
|
||||
optimized, changed, err := optimizeImage(data, filepath.Ext(src), opts)
|
||||
if err != nil {
|
||||
return imageFileStats{}, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return imageFileStats{}, err
|
||||
}
|
||||
|
||||
output := optimized
|
||||
if !changed {
|
||||
output = data
|
||||
}
|
||||
if err := os.WriteFile(dst, output, mode); err != nil {
|
||||
return imageFileStats{}, err
|
||||
}
|
||||
|
||||
return imageFileStats{
|
||||
originalBytes: int64(len(data)),
|
||||
outputBytes: int64(len(output)),
|
||||
optimized: changed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func optimizeImage(data []byte, ext string, opts Options) ([]byte, bool, error) {
|
||||
img, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
orientation := 1
|
||||
if format == "jpeg" || strings.EqualFold(ext, ".jpg") || strings.EqualFold(ext, ".jpeg") {
|
||||
orientation = jpegOrientation(data)
|
||||
img = applyOrientation(img, orientation)
|
||||
}
|
||||
|
||||
srcBounds := img.Bounds()
|
||||
dstW, dstH := fitWithin(srcBounds.Dx(), srcBounds.Dy(), opts.MaxWidth, opts.MaxHeight)
|
||||
resized := dstW != srcBounds.Dx() || dstH != srcBounds.Dy()
|
||||
if resized {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, srcBounds, stddraw.Src, nil)
|
||||
img = dst
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if imageHasTransparency(img) {
|
||||
encoder := png.Encoder{CompressionLevel: png.BestCompression}
|
||||
err = encoder.Encode(&buf, img)
|
||||
} else {
|
||||
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: clampJPEGQuality(opts.JPEGQuality)})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
changed := resized || orientation != 1 || buf.Len() < len(data)
|
||||
if !changed {
|
||||
return data, false, nil
|
||||
}
|
||||
return buf.Bytes(), true, nil
|
||||
}
|
||||
|
||||
func fitWithin(width, height, maxWidth, maxHeight int) (int, int) {
|
||||
if width <= 0 || height <= 0 || maxWidth <= 0 || maxHeight <= 0 {
|
||||
return width, height
|
||||
}
|
||||
if width <= maxWidth && height <= maxHeight {
|
||||
return width, height
|
||||
}
|
||||
|
||||
scale := math.Min(float64(maxWidth)/float64(width), float64(maxHeight)/float64(height))
|
||||
return max(1, int(math.Round(float64(width)*scale))), max(1, int(math.Round(float64(height)*scale)))
|
||||
}
|
||||
|
||||
func imageHasTransparency(img image.Image) bool {
|
||||
switch m := img.(type) {
|
||||
case *image.YCbCr:
|
||||
return false
|
||||
case *image.Gray, *image.Gray16, *image.CMYK:
|
||||
return false
|
||||
case *image.NRGBA:
|
||||
for i := 3; i < len(m.Pix); i += 4 {
|
||||
a := m.Pix[i]
|
||||
if a != 0xff {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case *image.RGBA:
|
||||
for i := 3; i < len(m.Pix); i += 4 {
|
||||
a := m.Pix[i]
|
||||
if a != 0xff {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case *image.Alpha, *image.Alpha16:
|
||||
return true
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
_, _, _, a := img.At(x, y).RGBA()
|
||||
if a != 0xffff {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyOrientation(img image.Image, orientation int) image.Image {
|
||||
if orientation < 2 || orientation > 8 {
|
||||
return img
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
dstW, dstH := w, h
|
||||
if orientation >= 5 {
|
||||
dstW, dstH = h, w
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
for y := 0; y < dstH; y++ {
|
||||
for x := 0; x < dstW; x++ {
|
||||
sx, sy := orientedSourcePoint(x, y, w, h, orientation)
|
||||
dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy))
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func orientedSourcePoint(x, y, width, height, orientation int) (int, int) {
|
||||
switch orientation {
|
||||
case 2:
|
||||
return width - 1 - x, y
|
||||
case 3:
|
||||
return width - 1 - x, height - 1 - y
|
||||
case 4:
|
||||
return x, height - 1 - y
|
||||
case 5:
|
||||
return y, x
|
||||
case 6:
|
||||
return y, height - 1 - x
|
||||
case 7:
|
||||
return width - 1 - y, height - 1 - x
|
||||
case 8:
|
||||
return width - 1 - y, x
|
||||
default:
|
||||
return x, y
|
||||
}
|
||||
}
|
||||
|
||||
func jpegOrientation(data []byte) int {
|
||||
if len(data) < 4 || data[0] != 0xff || data[1] != 0xd8 {
|
||||
return 1
|
||||
}
|
||||
|
||||
for i := 2; i+4 <= len(data); {
|
||||
if data[i] != 0xff {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
for i < len(data) && data[i] == 0xff {
|
||||
i++
|
||||
}
|
||||
if i >= len(data) {
|
||||
return 1
|
||||
}
|
||||
|
||||
marker := data[i]
|
||||
i++
|
||||
if marker == 0xd9 || marker == 0xda {
|
||||
return 1
|
||||
}
|
||||
if marker >= 0xd0 && marker <= 0xd7 {
|
||||
continue
|
||||
}
|
||||
if i+2 > len(data) {
|
||||
return 1
|
||||
}
|
||||
|
||||
segmentLen := int(binary.BigEndian.Uint16(data[i : i+2]))
|
||||
i += 2
|
||||
if segmentLen < 2 || i+segmentLen-2 > len(data) {
|
||||
return 1
|
||||
}
|
||||
|
||||
payload := data[i : i+segmentLen-2]
|
||||
if marker == 0xe1 && bytes.HasPrefix(payload, []byte("Exif\x00\x00")) {
|
||||
return tiffOrientation(payload[6:])
|
||||
}
|
||||
i += segmentLen - 2
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
func tiffOrientation(data []byte) int {
|
||||
if len(data) < 8 {
|
||||
return 1
|
||||
}
|
||||
|
||||
var order binary.ByteOrder
|
||||
switch string(data[:2]) {
|
||||
case "II":
|
||||
order = binary.LittleEndian
|
||||
case "MM":
|
||||
order = binary.BigEndian
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
if order.Uint16(data[2:4]) != 42 {
|
||||
return 1
|
||||
}
|
||||
|
||||
ifdOffset := int(order.Uint32(data[4:8]))
|
||||
if ifdOffset < 0 || ifdOffset+2 > len(data) {
|
||||
return 1
|
||||
}
|
||||
|
||||
entryCount := int(order.Uint16(data[ifdOffset : ifdOffset+2]))
|
||||
pos := ifdOffset + 2
|
||||
for i := 0; i < entryCount; i++ {
|
||||
if pos+12 > len(data) {
|
||||
return 1
|
||||
}
|
||||
|
||||
tag := order.Uint16(data[pos : pos+2])
|
||||
fieldType := order.Uint16(data[pos+2 : pos+4])
|
||||
count := order.Uint32(data[pos+4 : pos+8])
|
||||
if tag == 0x0112 && fieldType == 3 && count == 1 {
|
||||
value := int(order.Uint16(data[pos+8 : pos+10]))
|
||||
if value >= 1 && value <= 8 {
|
||||
return value
|
||||
}
|
||||
return 1
|
||||
}
|
||||
pos += 12
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
func isSupportedImage(path string) bool {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".jpg", ".jpeg", ".png":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, mode fs.FileMode) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func copySymlink(src, dst string) error {
|
||||
target, err := os.Readlink(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(target, dst)
|
||||
}
|
||||
|
||||
func clampJPEGQuality(q int) int {
|
||||
if q <= 0 {
|
||||
return 82
|
||||
}
|
||||
if q < 1 {
|
||||
return 1
|
||||
}
|
||||
if q > 100 {
|
||||
return 100
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func FormatBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n >= unit*div && exp < 4 {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
100
internal/pdfassets/pdfassets_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package pdfassets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFitWithin(t *testing.T) {
|
||||
w, h := fitWithin(400, 200, 100, 100)
|
||||
if w != 100 || h != 50 {
|
||||
t.Fatalf("fitWithin landscape = %dx%d, want 100x50", w, h)
|
||||
}
|
||||
|
||||
w, h = fitWithin(200, 400, 100, 100)
|
||||
if w != 50 || h != 100 {
|
||||
t.Fatalf("fitWithin portrait = %dx%d, want 50x100", w, h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOrientationRightTop(t *testing.T) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 3))
|
||||
for y := 0; y < 3; y++ {
|
||||
for x := 0; x < 2; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), A: 0xff})
|
||||
}
|
||||
}
|
||||
|
||||
got := applyOrientation(img, 6)
|
||||
if got.Bounds().Dx() != 3 || got.Bounds().Dy() != 2 {
|
||||
t.Fatalf("oriented bounds = %v, want 3x2", got.Bounds())
|
||||
}
|
||||
assertRGBA(t, got.At(0, 0), color.RGBA{R: 0, G: 2, A: 0xff})
|
||||
assertRGBA(t, got.At(2, 1), color.RGBA{R: 1, G: 0, A: 0xff})
|
||||
}
|
||||
|
||||
func TestPrepareTreeOptimizesImagesAndSkipsPDFs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "assets", "img"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte(`<img src="assets/img/photo.jpg">`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "old.pdf"), []byte("old pdf"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var src bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 400, 200))
|
||||
for y := 0; y < 200; y++ {
|
||||
for x := 0; x < 400; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 120, A: 0xff})
|
||||
}
|
||||
}
|
||||
if err := jpeg.Encode(&src, img, &jpeg.Options{Quality: 95}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "assets", "img", "photo.jpg"), src.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prepared, err := PrepareTree(root, Options{MaxWidth: 100, MaxHeight: 100, JPEGQuality: 70})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer prepared.Cleanup()
|
||||
|
||||
if prepared.Stats.ImageFiles != 1 || prepared.Stats.OptimizedImages != 1 {
|
||||
t.Fatalf("stats = %+v, want one optimized image", prepared.Stats)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(prepared.Root, "old.pdf")); !os.IsNotExist(err) {
|
||||
t.Fatalf("old PDF copied into prepared tree; err=%v", err)
|
||||
}
|
||||
|
||||
out, err := os.Open(filepath.Join(prepared.Root, "assets", "img", "photo.jpg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer out.Close()
|
||||
cfg, err := jpeg.DecodeConfig(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Width > 100 || cfg.Height > 100 {
|
||||
t.Fatalf("optimized dimensions = %dx%d, want within 100x100", cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRGBA(t *testing.T, got color.Color, want color.RGBA) {
|
||||
t.Helper()
|
||||
r, g, b, a := got.RGBA()
|
||||
if uint8(r>>8) != want.R || uint8(g>>8) != want.G || uint8(b>>8) != want.B || uint8(a>>8) != want.A {
|
||||
t.Fatalf("color = rgba(%d,%d,%d,%d), want %+v", uint8(r>>8), uint8(g>>8), uint8(b>>8), uint8(a>>8), want)
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,18 @@
|
||||
- cover.html
|
||||
- hack-the-planet.html
|
||||
- wtf.html
|
||||
- ai-disclosure.html
|
||||
- drivingmissmuni.html
|
||||
- crashman.html
|
||||
- vranklevictim.html
|
||||
- pearlstreetcafe.html
|
||||
- gtkapplang-1.html
|
||||
- gtkapplang-2.html
|
||||
- gtkapplang-3.html
|
||||
- gtkapplang-4.html
|
||||
- broken-screen.html
|
||||
- windows-shirt.html
|
||||
- free-dirt.html
|
||||
- mr-nickel.html
|
||||
- javaguy.html
|
||||
|
||||
|
||||
129
pages/ai-disclosure.html
Normal file
@@ -0,0 +1,129 @@
|
||||
<style>
|
||||
#ai-disclosure-page {
|
||||
background: url("assets/img/circuit-board.jpg") no-repeat center center;
|
||||
background-size: cover;
|
||||
|
||||
font: 8.75pt/1.40 Tahoma, sans-serif;
|
||||
padding: 10px;
|
||||
hyphens: auto;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: "Heading Now";
|
||||
font-size: 23px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: "Heading Now";
|
||||
font-size: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 12.75pt;
|
||||
}
|
||||
|
||||
/* Windows 98 styles — green/teal variant */
|
||||
div#overlay-window {
|
||||
background: #d4d0c8;
|
||||
|
||||
/* Vertically center the window in the page */
|
||||
position: relative;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
h1#titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
min-height: 22px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(90deg, #004000 0%, #10a050 50%, #004000 100%);
|
||||
}
|
||||
|
||||
h1#titlebar img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
h1#titlebar span {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
div#content {
|
||||
background: #f0f0e8c0;
|
||||
padding: 10px;
|
||||
color: #000000;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
span#dumb {
|
||||
font-family: "Heading Now";
|
||||
font-size: large;
|
||||
font-stretch: condensed;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
span.stuff {
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="ai-disclosure-page" class="page-base">
|
||||
<div id="overlay-window">
|
||||
<h1 id="titlebar">
|
||||
<img src="assets/img/computer_2_cool-3.png" alt="">
|
||||
<span>AI Disclosure</span>
|
||||
<img src="assets/img/computer_2_cool-3.png" alt="">
|
||||
</h1>
|
||||
<div id="content">
|
||||
<h2>A DISCLOSURE ABOUT AI USAGE</h2>
|
||||
|
||||
<p>
|
||||
Seems like everything is slop now days. And I'm not just talking about stuff
|
||||
generated by the <span class="stuff">"Large Language Models."</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You, dear reader, deserve better than slop. Therefore, we are proud to declare that
|
||||
nothing in this issue of <strong>SMART BAR</strong> is generated by A.I. We're
|
||||
talking 100% pure homegrown human creativity, the best this side of the Bay Area has
|
||||
to offer.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
That's not to say that we didn't try. Writing good quality content is hard, which is
|
||||
why we tasked our <strong>Tech Wizard</strong> <em>P. Michael Cho</em> with employing the
|
||||
newest, hottest transformers to create hard-hitting philosophical pieces for this
|
||||
issue.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Unfortunately, <span id="dumb">WE COULDN'T FIGURE IT OUT</span>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Despite paying a hundred bucks for <span class="stuff">G.P.T. Pro Max
|
||||
Extreme</span>, getting <span class="stuff">"API Keys,"</span> and doodling a
|
||||
couple of <span class="stuff">shell scripts</span>, we kept getting back <span
|
||||
class="stuff">MUFFIN RECIPES</span> and <span class="stuff">PYTHON
|
||||
PROGRAMS</span>. Needless to say, this is not content that you spent your hard
|
||||
earned cash on to read.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
In any case, we hope you enjoy these words and ideas from a couple of baseline
|
||||
humans.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
19
pages/broken-screen.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<style>
|
||||
#broken-screen::before {
|
||||
background-image: url("assets/img/broken-screen.jpg");
|
||||
}
|
||||
|
||||
#broken-screen-caption {
|
||||
position: absolute;
|
||||
bottom: 25%;
|
||||
right: 15%;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="broken-screen" class="page-base interlude">
|
||||
<div class="interlude-caption" id="broken-screen-caption">
|
||||
have you tried<br/>
|
||||
turning it off and on again?
|
||||
</div>
|
||||
</div>
|
||||
89
pages/crashman.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<style>
|
||||
#crashman-page {
|
||||
position: relative;
|
||||
background: url("assets/img/crashman/crashman-bg.png") no-repeat center center;
|
||||
background-size: cover;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#snips {
|
||||
height: 120px;
|
||||
width: 620px;
|
||||
background-color: rgb(209, 1, 209);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: -50px;
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
|
||||
#snips img {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
#title {
|
||||
position: absolute;
|
||||
top: 350px;
|
||||
right: 8px;
|
||||
|
||||
font-family: "Heading Now";
|
||||
font-size: 48px;
|
||||
|
||||
color: #fff;
|
||||
text-shadow:
|
||||
0px 10px 0 rgb(209, 1, 209),
|
||||
0px 20px 0 black;
|
||||
}
|
||||
|
||||
#text {
|
||||
position: absolute;
|
||||
top: 390px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background-color: rgba(62, 6, 62, 0.704);
|
||||
font: 11.75pt/1.40 Tahoma, sans-serif;
|
||||
padding: 24px;
|
||||
color: #fff;
|
||||
line-height: 1.9em;
|
||||
}
|
||||
|
||||
.stretch {
|
||||
font-weight: 800;
|
||||
transform: scaleX(2.1);
|
||||
transform-origin: left;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div id="crashman-page" class="page-base">
|
||||
|
||||
<div id="text">
|
||||
<p>
|
||||
<b>Rusty Haight</b> is the human crash-test dummy. As director of the San Diego-based Collision Safety Institute,
|
||||
Rusty has experienced more than 950 violent vehicle crash tests at speeds of up to 54 mph,
|
||||
and taken <span class="stretch">140 air bags to the face.</span>
|
||||
</p>
|
||||
<p>
|
||||
Haight has contributed several publications on the topic of automobile safety
|
||||
in <b>Collision Magazine</b> such as <em>Hyundai and Kia Crash Data: the Indispensable Compendium.</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="snips">
|
||||
<img src="assets/img/crashman/crashman-1.png" />
|
||||
<img src="assets/img/crashman/crashman-2.png" />
|
||||
<img src="assets/img/crashman/crashman-3.png" />
|
||||
</div>
|
||||
|
||||
<div id="title" class="ytmnd-1">crash man</div>
|
||||
|
||||
</div>
|
||||
15
pages/free-dirt.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<style>
|
||||
#free-dirt::before {
|
||||
background-image: url("assets/img/free-dirt.jpg");
|
||||
}
|
||||
|
||||
#free-dirt-caption {
|
||||
position: absolute;
|
||||
bottom: 25%;
|
||||
right: 25%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="free-dirt" class="page-base interlude">
|
||||
<div class="interlude-caption" id="free-dirt-caption">thanks...</div>
|
||||
</div>
|
||||
18
pages/javaguy.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<style>
|
||||
#javaguy::before {
|
||||
background-image: url("assets/img/javaguy.jpg");
|
||||
}
|
||||
|
||||
#javaguy-caption {
|
||||
position: absolute;
|
||||
top: 5%;
|
||||
left: 5%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="javaguy" class="page-base interlude">
|
||||
<div class="interlude-caption" id="javaguy-caption">
|
||||
i'm living in the real world<br/>
|
||||
and he's living in a <em>virtual machine...</em><br/>
|
||||
</div>
|
||||
</div>
|
||||
19
pages/mr-nickel.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<style>
|
||||
#mr-nickel::before {
|
||||
background-image: url("assets/img/mr-nickel.jpg");
|
||||
}
|
||||
|
||||
#mr-nickel-caption {
|
||||
position: absolute;
|
||||
bottom: 10%;
|
||||
right: 5%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="mr-nickel" class="page-base interlude">
|
||||
<div class="interlude-caption" id="mr-nickel-caption">
|
||||
mr. nickel says<br/>
|
||||
the financial collapse is imminent<br/>
|
||||
and it's all your fault!
|
||||
</div>
|
||||
</div>
|
||||
19
pages/windows-shirt.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<style>
|
||||
#windows-shirt::before {
|
||||
background-image: url("assets/img/windows-shirt.jpg");
|
||||
}
|
||||
|
||||
#windows-shirt-caption {
|
||||
position: absolute;
|
||||
bottom: 10%;
|
||||
left: 10%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="windows-shirt" class="page-base interlude">
|
||||
<div class="interlude-caption" id="windows-shirt-caption">
|
||||
we line up to store<br/>
|
||||
only 4 gigabytes allowed<br/>
|
||||
comrade, be grateful!
|
||||
</div>
|
||||
</div>
|
||||