Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docker/frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
COPY frontend/scripts ./scripts

# Font download configuration for internal deployments
ARG GOOGLE_SANS_CSS_URLS=""
ARG SKIP_UI_FONT_DOWNLOAD=""
ARG SKIP_PDF_FONT_DOWNLOAD=""
ENV GOOGLE_SANS_CSS_URLS=${GOOGLE_SANS_CSS_URLS}
ENV SKIP_UI_FONT_DOWNLOAD=${SKIP_UI_FONT_DOWNLOAD}
ENV SKIP_PDF_FONT_DOWNLOAD=${SKIP_PDF_FONT_DOWNLOAD}

# Install dependencies (this layer will be cached, only reinstall when package.json changes)
RUN npm ci && npm cache clean --force

Expand Down
8 changes: 8 additions & 0 deletions docker/standalone/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
COPY frontend/scripts ./scripts

# Font download configuration for internal deployments
ARG GOOGLE_SANS_CSS_URLS=""
ARG SKIP_UI_FONT_DOWNLOAD=""
ARG SKIP_PDF_FONT_DOWNLOAD=""
ENV GOOGLE_SANS_CSS_URLS=${GOOGLE_SANS_CSS_URLS}
ENV SKIP_UI_FONT_DOWNLOAD=${SKIP_UI_FONT_DOWNLOAD}
ENV SKIP_PDF_FONT_DOWNLOAD=${SKIP_PDF_FONT_DOWNLOAD}

# Install dependencies
RUN npm ci && npm cache clean --force

Expand Down
2 changes: 2 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ public/fonts/*.ttf
public/fonts/*.otf
public/fonts/*.woff
public/fonts/*.woff2
# Google Sans woff2 files (downloaded at build time, not checked in)
public/fonts/google-sans/
248 changes: 205 additions & 43 deletions frontend/scripts/download-fonts.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,41 @@
// SPDX-License-Identifier: Apache-2.0

/**
* Download fonts script for PDF generation
* - Supports mirror fallback
* - Supports skip via env
* - Shows progress & timeout
* Download fonts for:
* 1) PDF rendering (CJK)
* 2) Local web typography (Google Sans Flex / Google Sans)
*
* Features:
* - Mirror fallback
* - Skip via environment variables
* - Progress and timeout
* - Atomic write with temp file
* - Configurable Google Sans CSS source URL(s) for internal deployments
*/

const https = require('https')
const http = require('http')
const fs = require('fs')
const path = require('path')

/* =========================
* Environment switches
* ========================= */
/* Environment switches */
if (process.env.SKIP_FONT_DOWNLOAD === '1') {
console.log('🚫 Skip font download (SKIP_FONT_DOWNLOAD=1)')
process.exit(0)
}

/* =========================
* Font configuration
* ========================= */
const FONTS = [
/* Paths */
const FONTS_DIR = path.join(__dirname, '..', 'public', 'fonts')
const PDF_FONT_DIR = FONTS_DIR
const GOOGLE_SANS_DIR = path.join(FONTS_DIR, 'google-sans')
const GOOGLE_SANS_CSS_OUTPUT = path.join(__dirname, '..', 'src', 'app', 'google-sans-local.css')
const DOWNLOAD_TIMEOUT = 30_000 // 30s
const GOOGLE_FONTS_USER_AGENT =
process.env.GOOGLE_FONT_USER_AGENT ||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36'

/* PDF font configuration */
const PDF_FONTS = [
{
name: 'SourceHanSansSC-VF.ttf',
description: 'Source Han Sans SC Variable (CJK support for PDF)',
Expand All @@ -41,12 +52,20 @@ const FONTS = [
},
]

const FONTS_DIR = path.join(__dirname, '..', 'public', 'fonts')
const DOWNLOAD_TIMEOUT = 30_000 // 30s
/* Google Sans configuration */
const defaultGoogleSansCssUrls = [
'https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Google+Sans+Flex:wght@400;500;700&display=swap',
]

const configuredGoogleSansCssUrls = (process.env.GOOGLE_SANS_CSS_URLS || '')
.split(',')
.map(url => url.trim())
.filter(Boolean)

const googleSansCssUrls = configuredGoogleSansCssUrls.length
? configuredGoogleSansCssUrls
: defaultGoogleSansCssUrls

/* =========================
* Utilities
* ========================= */
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
Expand All @@ -59,24 +78,33 @@ function isFontComplete(filePath, minSize) {
return !minSize || size >= minSize
}

/* =========================
* Core download logic
* ========================= */
function downloadFile(url, destPath, maxRedirects = 5) {
function ensureDirectory(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
}

function resolveRedirectUrl(currentUrl, location) {
return new URL(location, currentUrl).toString()
}

function downloadFile(url, destPath, options = {}, maxRedirects = 5) {
const tempPath = destPath + '.downloading'
const protocol = url.startsWith('https') ? https : http
const headers = options.headers || {}

return new Promise((resolve, reject) => {
if (maxRedirects <= 0) {
reject(new Error('Too many redirects'))
return
}

const req = protocol.get(url, res => {
const req = protocol.get(url, { headers }, res => {
// Redirect support
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
console.log(` ↪ Redirect: ${res.headers.location}`)
return downloadFile(res.headers.location, destPath, maxRedirects - 1)
const redirectUrl = resolveRedirectUrl(url, res.headers.location)
console.log(` ↪ Redirect: ${redirectUrl}`)
return downloadFile(redirectUrl, destPath, options, maxRedirects - 1)
.then(resolve)
.catch(reject)
}
Expand Down Expand Up @@ -135,13 +163,53 @@ function downloadFile(url, destPath, maxRedirects = 5) {
})
}

async function downloadWithFallback(urls, destPath) {
function downloadText(url, options = {}, maxRedirects = 5) {
const protocol = url.startsWith('https') ? https : http
const headers = options.headers || {}

return new Promise((resolve, reject) => {
if (maxRedirects <= 0) {
reject(new Error('Too many redirects'))
return
}

const req = protocol.get(url, { headers }, res => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const redirectUrl = resolveRedirectUrl(url, res.headers.location)
return downloadText(redirectUrl, options, maxRedirects - 1).then(resolve).catch(reject)
}

if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`))
return
}

let data = ''
res.setEncoding('utf8')
res.on('data', chunk => {
data += chunk
})
res.on('end', () => resolve(data))
})

req.setTimeout(DOWNLOAD_TIMEOUT, () => {
req.destroy(new Error('Download timeout'))
})

req.on('error', reject)
})
}

async function downloadWithFallback(urls, downloader, validator) {
let lastError
for (const url of urls) {
try {
console.log(` 🌐 Try: ${url}`)
await downloadFile(url, destPath)
return
const result = await downloader(url)
if (validator) {
await validator(result, url)
}
return { url, result }
} catch (err) {
console.warn(` ⚠ Failed: ${err.message}`)
lastError = err
Expand All @@ -150,21 +218,35 @@ async function downloadWithFallback(urls, destPath) {
throw lastError
}

/* =========================
* Main
* ========================= */
async function main() {
console.log('📦 Downloading fonts for PDF generation...\n')
function normalizeFontUrl(rawUrl) {
return rawUrl.trim().replace(/^['"]|['"]$/g, '')
}

if (!fs.existsSync(FONTS_DIR)) {
fs.mkdirSync(FONTS_DIR, { recursive: true })
console.log(`Created directory: ${FONTS_DIR}\n`)
function collectFontUrlsFromCss(cssText) {
const regex = /url\(([^)]+)\)/g
const urls = []
for (const match of cssText.matchAll(regex)) {
const url = normalizeFontUrl(match[1])
if (url.startsWith('http://') || url.startsWith('https://')) {
urls.push(url)
}
}
return Array.from(new Set(urls))
}

function toLocalFileName(fontUrl, index) {
const urlObj = new URL(fontUrl)
const baseName = path.basename(urlObj.pathname)
const safeBaseName = baseName.replace(/[^A-Za-z0-9._-]/g, '_')
const ext = path.extname(safeBaseName) || '.woff2'
return `${String(index).padStart(2, '0')}-${safeBaseName || `font${ext}`}`
}

async function downloadPdfFonts() {
let hasErrors = false

for (const font of FONTS) {
const destPath = path.join(FONTS_DIR, font.name)
for (const font of PDF_FONTS) {
const destPath = path.join(PDF_FONT_DIR, font.name)
const tempPath = destPath + '.downloading'

if (fs.existsSync(tempPath)) {
Expand All @@ -185,13 +267,18 @@ async function main() {
console.log(` ${font.description}`)

try {
await downloadWithFallback(font.urls, destPath)
await downloadWithFallback(
font.urls,
url => downloadFile(url, destPath),
() => {
const { size } = fs.statSync(destPath)
if (font.minSize && size < font.minSize) {
throw new Error(`File too small (${formatSize(size)})`)
}
}
)

const { size } = fs.statSync(destPath)
if (font.minSize && size < font.minSize) {
throw new Error(`File too small (${formatSize(size)})`)
}

console.log(`✓ Downloaded ${font.name} (${formatSize(size)})\n`)
} catch (err) {
if (fs.existsSync(destPath)) fs.unlinkSync(destPath)
Expand All @@ -201,10 +288,85 @@ async function main() {
}
}

return !hasErrors
}

async function downloadGoogleSansAssets() {
try {
console.log('⬇ Downloading local Google Sans assets')
console.log(` CSS source candidates: ${googleSansCssUrls.join(', ')}`)

const { result: cssText } = await downloadWithFallback(googleSansCssUrls, url =>
downloadText(url, {
headers: {
'User-Agent': GOOGLE_FONTS_USER_AGENT,
},
})
)

const remoteFontUrls = collectFontUrlsFromCss(cssText)
if (remoteFontUrls.length === 0) {
throw new Error('No font URLs found in Google Sans CSS response')
}

ensureDirectory(GOOGLE_SANS_DIR)
const fileMap = new Map()

for (let i = 0; i < remoteFontUrls.length; i++) {
const remoteUrl = remoteFontUrls[i]
const fileName = toLocalFileName(remoteUrl, i + 1)
const destPath = path.join(GOOGLE_SANS_DIR, fileName)
fileMap.set(remoteUrl, fileName)

await downloadFile(remoteUrl, destPath, {
headers: {
'User-Agent': GOOGLE_FONTS_USER_AGENT,
},
})
}

const localCss = cssText.replace(/url\(([^)]+)\)/g, (match, rawUrl) => {
const normalized = normalizeFontUrl(rawUrl)
const localName = fileMap.get(normalized)
if (!localName) return match
return `url('/fonts/google-sans/${localName}')`
})

fs.writeFileSync(GOOGLE_SANS_CSS_OUTPUT, localCss, 'utf8')
console.log(`✓ Generated ${path.relative(process.cwd(), GOOGLE_SANS_CSS_OUTPUT)}\n`)
return true
} catch (err) {
console.error(`✗ Failed to download local Google Sans assets: ${err.message}`)
console.error(' Web UI will fall back to system fonts.\n')
return false
}
}

async function main() {
console.log('📦 Downloading font assets...\n')

ensureDirectory(FONTS_DIR)

let hasErrors = false

if (process.env.SKIP_PDF_FONT_DOWNLOAD !== '1') {
const pdfSuccess = await downloadPdfFonts()
if (!pdfSuccess) hasErrors = true
} else {
console.log('🚫 Skip PDF font download (SKIP_PDF_FONT_DOWNLOAD=1)\n')
}

if (process.env.SKIP_UI_FONT_DOWNLOAD !== '1') {
const uiSuccess = await downloadGoogleSansAssets()
if (!uiSuccess) hasErrors = true
} else {
console.log('🚫 Skip UI font download (SKIP_UI_FONT_DOWNLOAD=1)\n')
}

if (hasErrors) {
console.log('⚠ Some fonts failed to download. Build continues.')
console.log('⚠ Some font assets failed to download. Build continues.')
} else {
console.log('✅ All fonts downloaded successfully!')
console.log('✅ All font assets downloaded successfully!')
}
}

Expand Down
10 changes: 8 additions & 2 deletions frontend/src/apis/skillMarket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,10 @@ export async function searchSkills(params: SearchSkillsParams): Promise<SearchSk
// FastAPI wraps errors in a detail object, check for it first
const detail = errorData.detail
const errorMessage =
detail?.error || detail?.message || errorData.error || errorData.message ||
detail?.error ||
detail?.message ||
errorData.error ||
errorData.message ||
`HTTP ${response.status}: Failed to search skills`
throw new Error(errorMessage)
}
Expand Down Expand Up @@ -199,7 +202,10 @@ export async function downloadSkill(skillKey: string): Promise<Blob> {
// FastAPI wraps errors in a detail object, check for it first
const detail = errorData.detail
const errorMessage =
detail?.error || detail?.message || errorData.error || errorData.message ||
detail?.error ||
detail?.message ||
errorData.error ||
errorData.message ||
`HTTP ${response.status}: Failed to download skill`
throw new Error(errorMessage)
}
Expand Down
Loading
Loading