@@ -8,6 +8,18 @@ const REVIEW_OFFSETS = REVIEW_INTERVALS.reduce((offsets, interval) => {
88const PLAN_DAY_COUNT = CHAPTER_COUNT + REVIEW_OFFSETS . at ( - 1 ) ;
99const ANNOTATABLE_SELECTOR = "h2, h3, h4, p, li, blockquote, td, th" ;
1010const DISCUSSION_NEW_URL = "https://github.com/Lling0000/Vibe_coding_guide/discussions/new?category=q-a" ;
11+ const PDF_EXPORT_SCRIPTS = [
12+ "https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js" ,
13+ "https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js" ,
14+ ] ;
15+ const PDF_A4_PORTRAIT = {
16+ widthMm : 210 ,
17+ heightMm : 297 ,
18+ widthPx : 794 ,
19+ heightPx : 1122 ,
20+ rowsPerPage : 18 ,
21+ } ;
22+ const scriptPromises = new Map ( ) ;
1123
1224const docs = {
1325 zh : {
@@ -313,6 +325,7 @@ const copy = {
313325 matrixKicker : "Schedule Table" ,
314326 matrixTitle : "间隔学习总表" ,
315327 exportMatrixPdf : "导出 PDF" ,
328+ exportMatrixPdfBusy : "生成 A4 PDF..." ,
316329 exportMatrixPdfTitle : "Vibe Coding Guide · 36 天学习计划" ,
317330 resetMatrix : "清空表格勾选" ,
318331 resetMatrixConfirm : "确定要清空总表里的所有勾选吗?" ,
@@ -424,6 +437,7 @@ const copy = {
424437 matrixKicker : "Schedule Table" ,
425438 matrixTitle : "Spaced Learning Table" ,
426439 exportMatrixPdf : "Export PDF" ,
440+ exportMatrixPdfBusy : "Building A4 PDF..." ,
427441 exportMatrixPdfTitle : "Vibe Coding Guide · 36-Day Learning Plan" ,
428442 resetMatrix : "Clear table checks" ,
429443 resetMatrixConfirm : "Clear all table checks?" ,
@@ -2075,7 +2089,141 @@ function resetMatrixProgress() {
20752089 renderSchedulePlan ( ) ;
20762090}
20772091
2078- function exportSchedulePdf ( ) {
2092+ function loadScriptOnce ( src ) {
2093+ if ( scriptPromises . has ( src ) ) return scriptPromises . get ( src ) ;
2094+
2095+ const promise = new Promise ( ( resolve , reject ) => {
2096+ const existing = document . querySelector ( `script[src="${ src } "]` ) ;
2097+ if ( existing ?. dataset . loaded === "true" ) {
2098+ resolve ( ) ;
2099+ return ;
2100+ }
2101+
2102+ const script = existing || document . createElement ( "script" ) ;
2103+ script . src = src ;
2104+ script . async = true ;
2105+ script . crossOrigin = "anonymous" ;
2106+ script . addEventListener ( "load" , ( ) => {
2107+ script . dataset . loaded = "true" ;
2108+ resolve ( ) ;
2109+ } , { once : true } ) ;
2110+ script . addEventListener ( "error" , ( ) => reject ( new Error ( `Unable to load ${ src } ` ) ) , { once : true } ) ;
2111+
2112+ if ( ! existing ) {
2113+ document . head . append ( script ) ;
2114+ }
2115+ } ) ;
2116+
2117+ scriptPromises . set ( src , promise ) ;
2118+ return promise ;
2119+ }
2120+
2121+ async function loadPdfExportLibraries ( ) {
2122+ if ( window . html2canvas && window . jspdf ?. jsPDF ) return ;
2123+ await Promise . all ( PDF_EXPORT_SCRIPTS . map ( loadScriptOnce ) ) ;
2124+
2125+ if ( ! window . html2canvas || ! window . jspdf ?. jsPDF ) {
2126+ throw new Error ( "PDF export libraries are unavailable." ) ;
2127+ }
2128+ }
2129+
2130+ function schedulePdfFilename ( ) {
2131+ const lang = state . lang === "zh" ? "zh" : "en" ;
2132+ return `vibe-coding-guide-schedule-a4-portrait-${ lang } .pdf` ;
2133+ }
2134+
2135+ function createSchedulePdfCell ( item , text ) {
2136+ const cell = document . createElement ( "td" ) ;
2137+ if ( ! item ) return cell ;
2138+
2139+ const chapter = document . createElement ( "span" ) ;
2140+ chapter . className = "pdf-export-chapter" ;
2141+ chapter . textContent = text . matrixItem ( item . chapterIndex ) ;
2142+ cell . append ( chapter ) ;
2143+
2144+ if ( isChecked ( item . id ) ) {
2145+ const check = document . createElement ( "span" ) ;
2146+ check . className = "pdf-export-check" ;
2147+ check . textContent = "✓" ;
2148+ cell . append ( check ) ;
2149+ }
2150+
2151+ return cell ;
2152+ }
2153+
2154+ function createSchedulePdfTable ( startDay , endDay ) {
2155+ const text = copy [ state . lang ] ;
2156+ const columns = [
2157+ text . matrixDay ,
2158+ text . matrixLearn ,
2159+ ...REVIEW_INTERVALS . map ( ( _ , index ) => text . matrixReview ( index + 1 ) ) ,
2160+ ] ;
2161+
2162+ const table = document . createElement ( "table" ) ;
2163+ table . className = "pdf-export-table" ;
2164+
2165+ const thead = document . createElement ( "thead" ) ;
2166+ const headRow = document . createElement ( "tr" ) ;
2167+ columns . forEach ( ( label ) => {
2168+ const th = document . createElement ( "th" ) ;
2169+ th . scope = "col" ;
2170+ th . textContent = label ;
2171+ headRow . append ( th ) ;
2172+ } ) ;
2173+ thead . append ( headRow ) ;
2174+
2175+ const tbody = document . createElement ( "tbody" ) ;
2176+ for ( let day = startDay ; day <= endDay ; day += 1 ) {
2177+ const row = document . createElement ( "tr" ) ;
2178+ const dayHeader = document . createElement ( "th" ) ;
2179+ dayHeader . scope = "row" ;
2180+ dayHeader . textContent = text . dayLabel ( day ) ;
2181+ row . append ( dayHeader ) ;
2182+
2183+ const itemsByColumn = new Map ( getScheduleMatrixItems ( day ) . map ( ( item ) => [ item . column , item ] ) ) ;
2184+ for ( let column = 0 ; column <= REVIEW_INTERVALS . length ; column += 1 ) {
2185+ row . append ( createSchedulePdfCell ( itemsByColumn . get ( column ) , text ) ) ;
2186+ }
2187+
2188+ tbody . append ( row ) ;
2189+ }
2190+
2191+ table . append ( thead , tbody ) ;
2192+ return table ;
2193+ }
2194+
2195+ function createSchedulePdfRoot ( ) {
2196+ const text = copy [ state . lang ] ;
2197+ const pageCount = Math . ceil ( PLAN_DAY_COUNT / PDF_A4_PORTRAIT . rowsPerPage ) ;
2198+ const pageSizeLabel = state . lang === "zh" ? "A4 纵向" : "A4 portrait" ;
2199+ const root = document . createElement ( "div" ) ;
2200+ root . className = "pdf-export-root" ;
2201+ root . setAttribute ( "aria-hidden" , "true" ) ;
2202+
2203+ for ( let pageIndex = 0 ; pageIndex < pageCount ; pageIndex += 1 ) {
2204+ const startDay = pageIndex * PDF_A4_PORTRAIT . rowsPerPage + 1 ;
2205+ const endDay = Math . min ( PLAN_DAY_COUNT , startDay + PDF_A4_PORTRAIT . rowsPerPage - 1 ) ;
2206+ const page = document . createElement ( "section" ) ;
2207+ page . className = "pdf-export-page" ;
2208+
2209+ const header = document . createElement ( "header" ) ;
2210+ header . className = "pdf-export-header" ;
2211+ const kicker = document . createElement ( "p" ) ;
2212+ kicker . textContent = text . matrixKicker ;
2213+ const title = document . createElement ( "h1" ) ;
2214+ title . textContent = text . exportMatrixPdfTitle ;
2215+ const meta = document . createElement ( "span" ) ;
2216+ meta . textContent = `${ text . dayLabel ( startDay ) } - ${ text . dayLabel ( endDay ) } / ${ pageSizeLabel } ` ;
2217+ header . append ( kicker , title , meta ) ;
2218+
2219+ page . append ( header , createSchedulePdfTable ( startDay , endDay ) ) ;
2220+ root . append ( page ) ;
2221+ }
2222+
2223+ return root ;
2224+ }
2225+
2226+ function printSchedulePdfFallback ( ) {
20792227 const previousTitle = document . title ;
20802228 document . body . dataset . printMode = "schedule" ;
20812229 document . title = copy [ state . lang ] . exportMatrixPdfTitle ;
@@ -2092,6 +2240,65 @@ function exportSchedulePdf() {
20922240 window . requestAnimationFrame ( ( ) => window . print ( ) ) ;
20932241}
20942242
2243+ async function exportSchedulePdf ( ) {
2244+ const text = copy [ state . lang ] ;
2245+ const previousLabel = els . exportMatrixPdfLabel . textContent ;
2246+ els . exportMatrixPdf . disabled = true ;
2247+ els . exportMatrixPdfLabel . textContent = text . exportMatrixPdfBusy ;
2248+
2249+ let root = null ;
2250+ try {
2251+ await loadPdfExportLibraries ( ) ;
2252+ root = createSchedulePdfRoot ( ) ;
2253+ document . body . append ( root ) ;
2254+ await document . fonts ?. ready ;
2255+
2256+ const { jsPDF } = window . jspdf ;
2257+ const pdf = new jsPDF ( {
2258+ orientation : "portrait" ,
2259+ unit : "mm" ,
2260+ format : "a4" ,
2261+ compress : true ,
2262+ } ) ;
2263+ const pages = [ ...root . querySelectorAll ( ".pdf-export-page" ) ] ;
2264+
2265+ for ( const [ index , page ] of pages . entries ( ) ) {
2266+ const canvas = await window . html2canvas ( page , {
2267+ backgroundColor : "#ffffff" ,
2268+ logging : false ,
2269+ scale : 2 ,
2270+ useCORS : true ,
2271+ windowWidth : PDF_A4_PORTRAIT . widthPx ,
2272+ windowHeight : PDF_A4_PORTRAIT . heightPx ,
2273+ } ) ;
2274+
2275+ if ( index > 0 ) {
2276+ pdf . addPage ( "a4" , "portrait" ) ;
2277+ }
2278+
2279+ pdf . addImage (
2280+ canvas . toDataURL ( "image/png" ) ,
2281+ "PNG" ,
2282+ 0 ,
2283+ 0 ,
2284+ PDF_A4_PORTRAIT . widthMm ,
2285+ PDF_A4_PORTRAIT . heightMm ,
2286+ undefined ,
2287+ "FAST" ,
2288+ ) ;
2289+ }
2290+
2291+ pdf . save ( schedulePdfFilename ( ) ) ;
2292+ } catch ( error ) {
2293+ console . warn ( "A4 PDF export failed, falling back to browser print." , error ) ;
2294+ printSchedulePdfFallback ( ) ;
2295+ } finally {
2296+ root ?. remove ( ) ;
2297+ els . exportMatrixPdf . disabled = false ;
2298+ els . exportMatrixPdfLabel . textContent = previousLabel || text . exportMatrixPdf ;
2299+ }
2300+ }
2301+
20952302async function loadGuide ( lang , shouldUpdateUrl = true ) {
20962303 state . lang = lang ;
20972304 state . headings = [ ] ;
0 commit comments