import JSZip from 'jszip'; import { useState } from 'react'; interface UseCodeDownloadOptions { proposalName: string; } export function useCodeDownload({ proposalName }: UseCodeDownloadOptions) { const [isDownloading, setIsDownloading] = useState(false); const [error, setError] = useState(null); const downloadCode = async (fvHtml: string, cnHtml: string, fvCss: string, cnCss: string, defaultCss: string, mainCopy?: string) => { setIsDownloading(true); setError(null); // no-image.webpが含まれているかチェック const noImageCount = (cnHtml.match(/\/images\/no-image\.webp/g) || []).length; console.log('[Download] no-image.webp occurrences in CN HTML:', noImageCount); try { const zip = new JSZip(); // FVとCNのHTMLから画像を分離して管理 const fvImages: { [key: string]: string } = {}; const cnImages: { [key: string]: string } = {}; let fvImageCounter = 1; let cnImageCounter = 1; // デバッグ: 元のHTMLの画像形式を確認 const fvImgTagMatches = fvHtml.match(/]+src=["'][^"']+["'][^>]*>/gi); console.log('[Download] Found img tags in FV HTML:', fvImgTagMatches?.length || 0); if (fvImgTagMatches && fvImgTagMatches.length > 0) { console.log('[Download] Sample FV img tag:', fvImgTagMatches[0].substring(0, 200)); } const cnImgTagMatches = cnHtml.match(/]+src=["'][^"']+["'][^>]*>/gi); console.log('[Download] Found img tags in CN HTML:', cnImgTagMatches?.length || 0); if (cnImgTagMatches && cnImgTagMatches.length > 0) { console.log('[Download] Sample CN img tag:', cnImgTagMatches[0].substring(0, 200)); // data:image/jpeg;base64の形式かチェック const dataUrlMatches = cnHtml.match(/src=["']data:image\/jpeg;base64,[^"']+["']/gi); console.log('[Download] Found data:image/jpeg;base64 patterns:', dataUrlMatches?.length || 0); if (dataUrlMatches && dataUrlMatches.length > 0) { console.log('[Download] Sample data URL:', dataUrlMatches[0].substring(0, 100) + '...'); } } // FV画像の抽出と置換 - より柔軟なパターンマッチング let processedFvHtml = fvHtml; // Base64画像の処理(スペースや改行を許容) processedFvHtml = processedFvHtml.replace(/src\s*=\s*["']data:image\/([^;]+);base64,\s*([^"']+)["']/gi, (match, format, base64Data) => { // Base64データから空白文字を除去 const cleanBase64 = base64Data.replace(/\s+/g, ''); const imageName = `fv_img_${fvImageCounter.toString().padStart(3, '0')}.${format}`; fvImages[imageName] = cleanBase64; console.log(`[Download] Extracted FV image ${imageName}, size: ${cleanBase64.length}`); fvImageCounter++; return `src="common/images/fv/${imageName}"`; }); // コンテンツ画像の抽出と置換 let processedCnHtml = cnHtml; // Base64画像の処理(スペースや改行を許容) processedCnHtml = processedCnHtml.replace(/src\s*=\s*["']data:image\/([^;]+);base64,\s*([^"']+)["']/gi, (match, format, base64Data) => { // Base64データから空白文字を除去 const cleanBase64 = base64Data.replace(/\s+/g, ''); const imageName = `cn_img_${cnImageCounter.toString().padStart(3, '0')}.${format}`; cnImages[imageName] = cleanBase64; console.log(`[Download] Extracted CN image ${imageName}, size: ${cleanBase64.length}`); cnImageCounter++; return `src="common/images/content/${imageName}"`; }); // URLで参照されている画像を抽出して処理 const fvImageUrls = new Set(); const cnImageUrls = new Set(); // FV画像URLの抽出(元のHTMLと処理後のHTML両方から抽出) // 元のHTMLからURL画像を抽出(Base64に変換される前のURL画像を取得) const originalFvImgUrlMatches = fvHtml.matchAll(/]+src=["']([^"']+)["']/gi); for (const match of originalFvImgUrlMatches) { const url = match[1]; console.log('[Download] Found FV img src in original HTML:', url); // HTTPのURLまたは相対パス(/で始まる)の画像を処理 if ((url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//') || url.startsWith('/')) && !url.includes('data:image')) { fvImageUrls.add(url); console.log('[Download] Added to fvImageUrls from original:', url); } } // 処理後のHTMLからもURL画像を抽出(Base64処理されなかったURL画像を取得) const processedFvImgUrlMatches = processedFvHtml.matchAll(/]+src=["']([^"']+)["']/gi); for (const match of processedFvImgUrlMatches) { const url = match[1]; console.log('[Download] Found FV img src in processed HTML:', url); // HTTPのURLまたは相対パス(/で始まる)の画像を処理(common/images/fvは除外) if ( (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//') || url.startsWith('/')) && !url.includes('data:image') && !url.includes('common/images/fv') ) { fvImageUrls.add(url); console.log('[Download] Added to fvImageUrls from processed:', url); } } // コンテンツ画像URLの抽出 const cnImgUrlMatches = processedCnHtml.matchAll(/]+src=["']([^"']+)["']/gi); console.log('[Download] Extracting CN image URLs from processedCnHtml'); for (const match of cnImgUrlMatches) { const url = match[1]; console.log('[Download] Found img src in CN HTML:', url); // cn-image-error.webpは処理をスキップ(既にプレースホルダー) if (url.includes('/images/cn-image-error.webp')) { console.log('[Download] Skipping cn-image-error.webp'); continue; } // HTTPのURLまたは相対パス(/で始まる)の画像を処理 if ((url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//') || url.startsWith('/')) && !url.includes('data:image')) { cnImageUrls.add(url); console.log('[Download] Added to cnImageUrls:', url); } } console.log('[Download] Found FV image URLs:', fvImageUrls.size); console.log('[Download] Found CN image URLs:', cnImageUrls.size); // プレースホルダー画像のパスを相対パスに置換(最後の処理としても実行) const replaceNoImagePaths = () => { // cn-image-error.webp(AI生成エラー時)のパス置換 const hasErrorImageInCn = processedCnHtml.includes('/images/cn-image-error.webp'); if (hasErrorImageInCn) { console.log('[Download] cn-image-error.webp placeholder detected in content HTML'); processedCnHtml = processedCnHtml.replace(/src="\/images\/cn-image-error\.webp"/gi, 'src="common/images/cn-image-error.webp"'); console.log('[Download] Replaced cn-image-error.webp paths'); } // no-image.webp(画像クリア機能使用時)のパス置換 const hasNoImageInCn = processedCnHtml.includes('/images/no-image.webp'); if (hasNoImageInCn) { console.log('[Download] no-image.webp placeholder detected in content HTML'); processedCnHtml = processedCnHtml.replace(/src="\/images\/no-image\.webp"/gi, 'src="common/images/no-image.webp"'); console.log('[Download] Replaced no-image.webp paths'); } }; // 初回のパス置換 replaceNoImagePaths(); // FV画像URLからダウンロード let urlFvImageCounter = fvImageCounter; for (const imageUrl of fvImageUrls) { try { // 相対パスの場合は現在のオリジンを使用 let fullUrl = imageUrl; if (imageUrl.startsWith('//')) { fullUrl = `https:${imageUrl}`; } else if (imageUrl.startsWith('/')) { // 相対パスの場合は現在のオリジンを追加 fullUrl = `${window.location.origin}${imageUrl}`; } console.log(`[Download] Fetching FV image from: ${fullUrl}`); const response = await fetch(fullUrl); if (response.ok) { const blob = await response.blob(); const arrayBuffer = await blob.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const base64String = buffer.toString('base64'); // 拡張子を取得 const urlParts = imageUrl.split('?')[0].split('.'); const extension = urlParts[urlParts.length - 1] || 'jpg'; const imageName = `fv_img_${urlFvImageCounter.toString().padStart(3, '0')}.${extension}`; fvImages[imageName] = base64String; // HTMLの画像パスを置換(正規表現をエスケープ) const escapedUrl = imageUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); processedFvHtml = processedFvHtml.replace(new RegExp(`src=["']${escapedUrl}["']`, 'gi'), `src="common/images/fv/${imageName}"`); console.log(`[Download] Downloaded FV image: ${imageName} (${base64String.length} bytes)`); urlFvImageCounter++; } else { console.error(`[Download] Failed to fetch FV image (${response.status}): ${fullUrl}`); } } catch (error) { console.error(`[Download] Error downloading FV image from ${imageUrl}:`, error); } } // コンテンツ画像URLからダウンロード(URLが存在する場合のみ) if (cnImageUrls.size > 0) { console.log('[Download] Processing CN image URLs...'); let urlCnImageCounter = cnImageCounter; for (const imageUrl of cnImageUrls) { try { // 相対パスの場合は現在のオリジンを使用 let fullUrl = imageUrl; if (imageUrl.startsWith('//')) { fullUrl = `https:${imageUrl}`; } else if (imageUrl.startsWith('/')) { // 相対パスの場合は現在のオリジンを追加 fullUrl = `${window.location.origin}${imageUrl}`; } console.log(`[Download] Fetching CN image from: ${fullUrl}`); const response = await fetch(fullUrl); if (response.ok) { const blob = await response.blob(); const arrayBuffer = await blob.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const base64String = buffer.toString('base64'); // 拡張子を取得 const urlParts = imageUrl.split('?')[0].split('.'); const extension = urlParts[urlParts.length - 1] || 'jpg'; const imageName = `cn_img_${urlCnImageCounter.toString().padStart(3, '0')}.${extension}`; cnImages[imageName] = base64String; // HTMLの画像パスを置換(正規表現をエスケープ) const escapedUrl = imageUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); processedCnHtml = processedCnHtml.replace(new RegExp(`src=["']${escapedUrl}["']`, 'gi'), `src="common/images/content/${imageName}"`); console.log(`[Download] Downloaded CN image: ${imageName} (${base64String.length} bytes)`); urlCnImageCounter++; } else { console.error(`[Download] Failed to fetch CN image (${response.status}): ${fullUrl}`); } } catch (error) { console.error(`[Download] Error downloading CN image from ${imageUrl}:`, error); } } } else { console.log('[Download] No CN image URLs to download (all using cn-image-error.webp)'); } // FV CSS の処理(背景画像なども対応) let processedFvCss = fvCss; let fvBgImageCounter = 1; // CSSのurl()内のBase64画像を処理(クォート有無両対応、スペース許容) processedFvCss = processedFvCss.replace(/url\s*\(\s*["']?data:image\/([^;]+);base64,\s*([^"')]+)["']?\s*\)/gi, (match, format, base64Data) => { // Base64データから空白文字と括弧を除去 const cleanBase64 = base64Data.replace(/[\s)]+/g, ''); const imageName = `fv_bg_${fvBgImageCounter.toString().padStart(3, '0')}.${format}`; fvImages[imageName] = cleanBase64; console.log(`[Download] Extracted FV background image ${imageName}, size: ${cleanBase64.length}`); fvBgImageCounter++; return `url("common/images/fv/${imageName}")`; }); // CN CSS の処理(背景画像なども対応) let processedCnCss = cnCss; let cnBgImageCounter = 1; // CSSのurl()内のBase64画像を処理(クォート有無両対応、スペース許容) processedCnCss = processedCnCss.replace(/url\s*\(\s*["']?data:image\/([^;]+);base64,\s*([^"')]+)["']?\s*\)/gi, (match, format, base64Data) => { // Base64データから空白文字と括弧を除去 const cleanBase64 = base64Data.replace(/[\s)]+/g, ''); const imageName = `cn_bg_${cnBgImageCounter.toString().padStart(3, '0')}.${format}`; cnImages[imageName] = cleanBase64; console.log(`[Download] Extracted CN background image ${imageName}, size: ${cleanBase64.length}`); cnBgImageCounter++; return `url("common/images/content/${imageName}")`; }); // CSS内のURL画像も処理する関数 const processCssUrls = async (css: string, images: { [key: string]: string }, prefix: string): Promise => { let processedCss = css; const urlMatches = [...css.matchAll(/url\s*\(\s*["']?([^"')]+)["']?\s*\)/gi)]; let bgCounter = prefix === 'fv' ? fvBgImageCounter : cnBgImageCounter; for (const match of urlMatches) { const url = match[1]; if ((url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) && !url.includes('data:image')) { try { const fullUrl = url.startsWith('//') ? `https:${url}` : url; console.log(`[Download] Fetching CSS background image from: ${fullUrl}`); const response = await fetch(fullUrl); if (response.ok) { const blob = await response.blob(); const arrayBuffer = await blob.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const base64String = buffer.toString('base64'); // 拡張子を取得 const urlParts = url.split('?')[0].split('.'); const extension = urlParts[urlParts.length - 1] || 'jpg'; const imageName = `${prefix}_bg_${bgCounter.toString().padStart(3, '0')}.${extension}`; images[imageName] = base64String; // CSSの画像パスを置換(正規表現をエスケープ) const escapedUrl = url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); processedCss = processedCss.replace( new RegExp(`url\\s*\\(\\s*["']?${escapedUrl}["']?\\s*\\)`, 'gi'), `url("common/images/${prefix === 'fv' ? 'fv' : 'content'}/${imageName}")`, ); console.log(`[Download] Downloaded CSS background image: ${imageName}`); bgCounter++; } else { console.error(`[Download] Failed to fetch CSS image (${response.status}): ${fullUrl}`); } } catch (error) { console.error(`[Download] Error downloading CSS image from ${url}:`, error); } } } return processedCss; }; // FVとCN CSSの背景画像URLを処理 processedFvCss = await processCssUrls(processedFvCss, fvImages, 'fv'); processedCnCss = await processCssUrls(processedCnCss, cnImages, 'cn'); // 最終的にcn-image-error.webpのパスを再度確認・置換(URLダウンロード処理後) replaceNoImagePaths(); console.log('[Download] Total FV images extracted:', Object.keys(fvImages).length); console.log('[Download] Total CN images extracted:', Object.keys(cnImages).length); console.log('[Download] Final CN HTML first 1000 chars:', processedCnHtml.substring(0, 1000)); // ガイドライン準拠のHTML構造を作成 const htmlContent = ` ${proposalName} - Preview ${mainCopy ? `

${mainCopy}

` : ''}
${processedFvHtml}
${processedCnHtml}
`; // すべてのCSSを1つのmain.cssにまとめる const mainCssContent = `@charset "UTF-8"; /* ======================================== Reset CSS ======================================== */ * { margin: 0; padding: 0; box-sizing: border-box; } html, body { font-family: YuGothic, 'Yu Gothic Medium', 'Yu Gothic', -apple-system, BlinkMacSystemFont, Roboto, Meiryo, sans-serif; line-height: 1.6; -webkit-text-size-adjust: 100%; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background-color: #f9f9f9; } img { max-width: 100%; height: auto; vertical-align: bottom; display: block; } a { text-decoration: none; color: inherit; } ul, ol { list-style: none; } table { border-collapse: collapse; border-spacing: 0; } button { background-color: transparent; border: none; cursor: pointer; padding: 0; } /* ======================================== Container Styles (スマホサイズ固定) ======================================== */ .container { width: 412px; margin: 0 auto; padding: 0; background-color: #ffffff; box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); min-height: 100vh; } /* スマホやタブレットでの表示 */ @media (max-width: 412px) { .container { width: 100%; box-shadow: none; } } /* ======================================== Common Styles ======================================== */ /* デフォルトスタイルは削除(上記で定義済み) */ /* Screen reader only */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } /* ======================================== FV Section Styles ======================================== */ #fv-section { width: 100%; } /* FV固有のスタイル */ ${processedFvCss} /* ======================================== Content Section Styles ======================================== */ #content-section { width: 100%; } /* コンテンツ固有のスタイル */ ${processedCnCss} /* ======================================== Responsive Design ======================================== */ @media (max-width: 412px) { body { font-size: 14px; } #fv-section, #content-section { padding: 10px; } /* SP向けの調整 */ img { width: 100%; } }`; // ガイドライン準拠のディレクトリ構成でzipに追加 // ルートHTML zip.file('index.html', htmlContent); // CSS ディレクトリ const stylesFolder = zip.folder('common/styles'); if (stylesFolder) { stylesFolder.file('main.css', mainCssContent); } // 画像ディレクトリ const imagesFolder = zip.folder('common/images'); if (imagesFolder) { // FV画像フォルダ const fvImagesFolder = imagesFolder.folder('fv'); if (fvImagesFolder) { Object.entries(fvImages).forEach(([imageName, base64Data]) => { console.log(`[Download] Adding FV image to ZIP: ${imageName}`); fvImagesFolder.file(imageName, base64Data, { base64: true }); }); } // コンテンツ画像フォルダ const contentImagesFolder = imagesFolder.folder('content'); if (contentImagesFolder) { Object.entries(cnImages).forEach(([imageName, base64Data]) => { console.log(`[Download] Adding CN image to ZIP: ${imageName}`); contentImagesFolder.file(imageName, base64Data, { base64: true }); }); } // cn-image-error.webpファイルを追加(エラー時用) try { const response = await fetch('/images/cn-image-error.webp'); const blob = await response.blob(); const arrayBuffer = await blob.arrayBuffer(); imagesFolder.file('cn-image-error.webp', arrayBuffer); console.log('[Download] Added cn-image-error.webp to ZIP'); } catch (e) { console.warn('[Download] Failed to load cn-image-error.webp, using fallback'); // フォールバック: 簡単なダミー画像 const dummyImage = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; imagesFolder.file('cn-image-error.webp', dummyImage, { base64: true }); } // no-image.webpファイルを追加(画像クリア機能用) try { const response = await fetch('/images/no-image.webp'); const blob = await response.blob(); const arrayBuffer = await blob.arrayBuffer(); imagesFolder.file('no-image.webp', arrayBuffer); console.log('[Download] Added no-image.webp to ZIP'); } catch (e) { console.warn('[Download] Failed to load no-image.webp, using fallback'); // フォールバック: 簡単なダミー画像 const dummyImage = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; imagesFolder.file('no-image.webp', dummyImage, { base64: true }); } // ダミーのファビコンファイルを追加(実際の案件では適切なファビコンを用意) const dummyFavicon = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; imagesFolder.file('favicon.ico', dummyFavicon, { base64: true }); imagesFolder.file('apple-touch-icon.png', dummyFavicon, { base64: true }); imagesFolder.file('android-192x192.png', dummyFavicon, { base64: true }); imagesFolder.file('img_ogp.jpg', dummyFavicon, { base64: true }); } // README.md を追加(制作ガイドライン情報) const readmeContent = `# ${proposalName} - LP制作ファイル ## ファイル構成 本ファイルは電通デジタル HTML制作ガイドライン ver1.2 に準拠しています。 \`\`\` / ├─ index.html # メインHTMLファイル ├─ common/ # サイト共通ファイル │ ├─ images/ # 画像ファイル │ │ ├─ fv/ # FV画像 │ │ │ ├─ fv_img_001.jpg │ │ │ ├─ fv_bg_001.jpg (背景画像) │ │ │ └─ ... │ │ ├─ content/ # コンテンツ画像 │ │ │ ├─ cn_img_001.jpg │ │ │ ├─ cn_bg_001.jpg (背景画像) │ │ │ └─ ... │ │ ├─ cn-image-error.webp # プレースホルダー画像 │ │ ├─ favicon.ico # ファビコン │ │ ├─ apple-touch-icon.png │ │ ├─ android-192x192.png │ │ └─ img_ogp.jpg # OGP画像 │ └─ styles/ # CSSファイル │ └─ main.css # 統合CSS(リセット + FV + コンテンツ + 共通) └─ README.md # このファイル \`\`\` ## CSS構成 ### main.css 1つのCSSファイルにすべてのスタイルを統合: - **Reset CSS**: ブラウザ間の差異を統一 - **Common Styles**: 全体共通のスタイル - **FV Section Styles**: FVセクション専用スタイル - **Content Section Styles**: コンテンツセクション専用スタイル ## 画像管理 ### FV画像 (/common/images/fv/) - FVセクションで使用される画像 - ファイル名: fv_img_xxx.jpg(img要素の画像) - ファイル名: fv_bg_xxx.jpg(CSS背景画像) ### コンテンツ画像 (/common/images/content/) - コンテンツセクションで使用される画像 - ファイル名: cn_img_xxx.jpg(img要素の画像) - ファイル名: cn_bg_xxx.jpg(CSS背景画像) ### プレースホルダー画像 - **cn-image-error.webp**: 画像クリア機能使用時のプレースホルダー ## 推奨環境 ### PC - Windows 最新版/Microsoft Edge 最新版、Chrome 最新版 - Mac OS X 最新版/Safari 最新版 ### SP - iOS 最新版/Safari 最新版 - Android 最新版/Chrome 最新版 ## 表示仕様 - **幅412px固定表示**:PCでもスマホサイズ(幅412px)で表示 - **左右中央配置**:画面が広い場合でも左右中央に配置 - **背景色**:コンテナ外は薄いグレー(#f9f9f9)、コンテナ内は白(#ffffff) - **ボックスシャドウ**:コンテナに適用してスマホ画面を演出 ## ブレイクポイント - 413px以上:幅412px固定で中央配置 - 412px以下:画面幅100%表示 ## 注意事項 - ファビコンファイルは仮のものです。実際の案件では適切なファビコンに差し替えてください。 - canonical URLや OGP画像URLは example.com になっています。実際のドメインに変更してください。 - 画像はFVとコンテンツで分離管理されています。用途に応じて適切なフォルダから参照してください。 - CSSは1つのファイルに統合されていますが、セクションごとにコメントで区切られています。 `; zip.file('README.md', readmeContent); // zipファイル生成 console.log('[Download] Generating ZIP file...'); const zipBlob = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(zipBlob); const link = document.createElement('a'); link.href = url; link.download = `${proposalName}-guideline-compliant.zip`; document.body.appendChild(link); link.click(); document.body.removeChild(link); // メモリリークを防ぐためURLを解放 URL.revokeObjectURL(url); console.log('[Download] Download completed successfully'); } catch (e: unknown) { const message = e instanceof Error ? e.message : 'ダウンロードに失敗しました'; console.error('[Download] Error:', message); setError(message); } finally { setIsDownloading(false); } }; return { downloadCode, isDownloading, error }; }