iT邦幫忙

3

如何使用JS產生PDF檔

大家好,
我想請問一下,
我想做一個存檔的按鈕,按下後會將chart.js的圖,
export成一個PDF檔。(之後還要做一個按鈕,將chart.js的圖存成SVG檔)

我爬了一些文,全部都是利用jsPDF這個套件去製作。
好處是方便、快速。
但是有個問題,這個套件大小要708KB,而我的專案總共也才1.5MB.....
為了這一個小小的功能,專案大小要多50%,有些不符合成本。

如果我想用原生的JS做出這個功能,要怎麼做呢?
還是有更方便的方法?
感謝
這是我目前的code(目前功能只有存出jpg png)

<script setup>
    import { onMounted } from '@vue/runtime-core';
    import Chart from 'chart.js/auto';

    onMounted(()=>{
        const ctx = document.getElementById('myChart');
        const myChart = new Chart(ctx, {
            type: 'bar',
            data: {
                labels: ['Red', 'Blue', 'Yellow'],
                datasets: [{
                    label: '# of Votes',
                    data: [12, 19, 3],
                    backgroundColor: [
                        'rgba(255, 99, 132, 0.2)',
                        'rgba(54, 162, 235, 0.2)',
                        'rgba(255, 206, 86, 0.2)'
                    ],
                    borderColor: [
                        'rgba(255, 99, 132, 1)',
                        'rgba(54, 162, 235, 1)',
                        'rgba(255, 206, 86, 1)'
                    ],
                    borderWidth: 1
                }]
            },
            options: {
                scales: {
                    y: {
                        beginAtZero: true
                    }
                }
            }
        })
        //下載
        const downloadBtn = document.getElementById("download_a")
        downloadBtn.addEventListener('click', function(){
            var url_base64jp =ctx.toDataURL("image/jpg");
            var a =  document.getElementById("download_a");
            a.href = url_base64jp;
        });
    })   
</script>

<template>
    <div class="outSide">
        <canvas id="myChart"></canvas>
    </div>
    <a id="download_a"
        download="ChartImage.jpg" 
        href=""
        title="Descargar Gráfico">下載</a>
</template>
<style scoped>
.outSide{
    max-width: 700px;
}
    #myChart{
        width: 100%;
        height: 100%;
    }
</style>

小山丘 iT邦新手 4 級 ‧ 2021-11-26 12:03:23 檢舉
708KB還好吧,不然用這方式就不會增加大小了,但前提要可以連網拉
<script src="https://unpkg.com/jspdf@latest/dist/jspdf.min.js"></script>
淺水員 iT邦高手 2 級 ‧ 2021-11-26 18:47:31 檢舉
pdf 裡面只要塞點陣圖就可以了嗎?
如果是 jpeg 的話,我是有辦法 10 k 內弄出來
期待樓主寫出超越jspdf並且檔案小於708KB的plugin

2 個回答

2
淺水員
iT邦高手 2 級 ‧ 2021-11-26 20:52:52
最佳解答

直接用 js 而不使用函式庫的話
以下是一個範例
這會把一張 jpeg 畫在 pdf 上
並在網頁上的 iframe 呈現

html

<iframe width="800" height="600" frameborder="0"></iframe>
<script src="pdf.js"></script>
<script src="index.js"></script>

pdf.js

/**
 * 產生 pdf 的基本工具
 * 先用 addObject 寫入所需的物件
 * 最後用 output 輸出
 * 注意:這個檔案必須是 utf8 編碼,且換行為 lf
 */
function Pdf() {
    this.idCount = 0;
    this.data = [];
}

/**
 * 加入一個 obj
 * 
 * @param {object} dict 
 * @param {string|flase} stream 
 * @param {int|undefined} specId
 */
Pdf.prototype.addObject = function (dict, stream, specId) {
    if (typeof stream === 'string') {
        dict['Length'] = stream.length;
    }
    let arr = [];
    let id;
    if (specId === undefined) {
        id = ++this.idCount;
    } else {
        id = specId;
    }
    arr.push(`${id} 0 obj`);
    arr.push('<<');
    for (let key in dict) {
        arr.push(`/${key} ${dict[key]}`);
    }
    arr.push('>>');
    if (typeof stream === 'string') {
        arr.push('stream');
        arr.push(stream);
        arr.push('endstream');
    }
    arr.push('endobj');
    this.data[id - 1] = arr.join('\n');
}

/**
 * 預留 id
 * 
 * @returns {int} 預留的 id
 */
Pdf.prototype.preserveId = function () {
    return ++this.idCount;
}

/**
 * 產生 pdf 的內容
 * 
 * @param {int} rootId 
 * @returns {string} pdf內容
 */
Pdf.prototype.output = function (rootId) {
    let header = '%PDF-1.4\n%§§';
    let xrefArr = ['0000000000 65535 f '];
    let pos = header.length + 3;
    this.data.forEach((obj) => {
        let s = pos.toString();
        s = '0'.repeat(10 - s.length) + s;
        xrefArr.push(`${s} 00000 n `);
        pos += obj.length + 1;
    });
    return `${header}
${this.data.join('\n')}
xref
0 ${this.data.length + 1}
${xrefArr.join('\n')}
trailer
<<
/Size ${this.data.length + 1}
/Root ${rootId} 0 R
>>
startxref
${pos}
%%EOF
`;
}

/**
 * 產生 pdf 的內容
 * 
 * @param {int} rootId 
 * @returns {string} pdf 的 dataurl
 */
Pdf.prototype.toDataUrl=function(rootId) {
    return 'data:application/pdf;base64,'+btoa_utf8(this.output(rootId));
}

/**
 * btoa 的 utf8 版本
 * @param {string} str 字串
 * @returns {string} base64字串
 */
function btoa_utf8(str) {
    const u8enc=new TextEncoder();
    return btoa(Array.from(u8enc.encode(str), x=>String.fromCodePoint(x)).join(''));
}

index.js

/**
 * 產生測試圖片
 * 一個 200 x 200 中間畫圓的 jpg
 * 
 * @returns {string} base64 data url
 */
function getTestJpg() {
    let cvs = document.createElement('canvas');
    cvs.width = 200;
    cvs.height = 200;
    let ctx = cvs.getContext('2d');
    ctx.fillStyle = "rgb(255,255,255)";
    ctx.fillRect(0, 0, 200, 200);
    ctx.beginPath();
    ctx.arc(100, 100, 50, 0, Math.PI * 2);
    ctx.stroke();
    return cvs.toDataURL('image/jpeg', 1.0);
}

/**
 * 把圖片畫到 pdf 上
 * 並產生 pdf 的 dataurl
 * 
 * @param {string} jpegBase64 jpeg的dataurl
 * @param {object} opt 用來指定位置與長寬 {x:x座標, y:y座標, width:寬度, height:高度}
 * @returns {string} pdf 的 dataurl
 */
function drawToPdf(jpegBase64, opt) {
    let jpegbase85 = base64ToBase85(jpegBase64.split('base64,')[1]); //轉成 base85
    /**
     * pdf 的結構請參考
     * https://www.adobe.com/content/dam/acom/en/devnet/pdf/pdfs/PDF32000_2008.pdf
     */
    let pdf = new Pdf();
    let catlogId = pdf.preserveId();
    let pagesId = pdf.preserveId();
    let firstPageId = pdf.preserveId();
    let resourceId = pdf.preserveId();
    let imgId = pdf.preserveId();
    let contentId = pdf.preserveId();
    pdf.addObject({
        Type: '/Catalog',
        Pages: `${pagesId} 0 R`,
    }, false, catlogId);
    pdf.addObject({
        Type: '/Pages',
        Kids: `[${firstPageId} 0 R]`,
        Count: 1
    }, false, pagesId);
    pdf.addObject({
        Type: '/Page',
        Parent: `${pagesId} 0 R`,
        Resources: `${resourceId} 0 R`,
        Contents: `[${contentId} 0 R]`,
        MediaBox: '[0 0 595.27559 841.88976]' //A4 大小
    }, false, firstPageId);
    pdf.addObject({
        ProcSet: '[/PDF /ImageB ]',
        XObject: `<< /Im1 ${imgId} 0 R >>`
    }, false, resourceId);
    pdf.addObject({
        Type: '/XObject',
        Subtype: '/Image',
        Width: opt.width,
        Height: opt.height,
        ColorSpace: '/DeviceRGB',
        BitsPerComponent: '8',
        Filter: '[/ASCII85Decode /DCTDecode]'
    }, jpegbase85+'~>', imgId);
    pdf.addObject({}, `${opt.width} 0 0 ${opt.height} ${opt.x} ${opt.y} cm /Im1 Do`, contentId);
    return pdf.toDataUrl(catlogId);

    /**
     * 把 base64 轉 base85
     * @param {string} base64 base64字串
     * @returns {string} base85字串
     */
    function base64ToBase85(base64) {
        let arr = Array.from(atob(base64), x => x.codePointAt());
        let padN = arr.length % 4 > 0 ? 4 - arr.length % 4 : 0;
        while (padN-- > 0) {
            arr.push(0);
        }
        //每4個一組
        let result = '';
        for (let i = 0, n = arr.length; i + 3 < n; i += 4) {
            let H = (arr[i] << 8 | arr[i + 1]);
            let L = (arr[i + 2] << 8 | arr[i + 3]);
            let tmp = '';
            for (let j = 0; j < 5; ++j) {
                let t = (H % 85 << 16 | L % 85);
                let r = t % 85; //對 85 取餘數
                //除 85 
                H = (H - H % 85) / 85;
                L = (L - L % 85) / 85 + (t - t % 85) / 85;
                H += L >>> 16;
                L&=0xffff;
                tmp = String.fromCodePoint(33 + r) + tmp;
            }
            result += tmp;
        }
        return result;
    }
}

const jpgData = getTestJpg();
const pdfDataUrl = drawToPdf(jpgData, {
    x: 100,
    y: 600,
    width: 200,
    height: 200
});

document.querySelector('iframe').src = pdfDataUrl;
看更多先前的回應...收起先前的回應...

淺水員
是否可以允許我將這段程式碼收錄到我的論壇內?
我會保留你的名字跟出處。

淺水員 iT邦高手 2 級 ‧ 2021-11-27 08:50:21 檢舉

可以,有需要的話也可以修改。

㊣浩瀚星空㊣
剛剛我有再修過,修正後的 Pdf.addObject 可以不用跟 Pdf.preserveId 的順序一樣,可以任意對調了。

好的。其實我是等你認可。我才會將程式碼移轉。
已將大作放入我的論壇上了。
感謝啦!!

淺水員 iT邦高手 2 級 ‧ 2021-11-27 16:49:03 檢舉

補上兩個範例

如果原發問者能轉成 svg
那麼有可能像範例二的方式直接用路徑的方式畫出圖表
而不是透過點陣圖的方式
(不過還有文字的部分要處理,會比較麻煩)

/**
 * 空白頁範例(最簡單的 pdf)
 * 
 * @returns {string} pdf 的 dataurl
 */
function simplePdf() {
    let pdf = new Pdf();
    let catlogId = pdf.preserveId();
    let pagesId = pdf.preserveId();
    let firstPageId = pdf.preserveId();
    pdf.addObject({
        Type: '/Catalog',
        Pages: `${pagesId} 0 R`,
    }, false, catlogId);
    pdf.addObject({
        Type: '/Pages',
        Kids: `[${firstPageId} 0 R]`,
        Count: 1
    }, false, pagesId);
    pdf.addObject({
        Type: '/Page',
        Parent: `${pagesId} 0 R`,
        Resources: `<< >>`,
        Contents: `[]`,
        MediaBox: '[0 0 595.27559 841.88976]' //A4 大小
    }, false, firstPageId);
    return pdf.toDataUrl(catlogId);
}

/**
 * 向量圖範例
 * 
 * @returns {string} pdf 的 dataurl
 */
function drawPathPdf() {
    let pdf = new Pdf();
    let catlogId = pdf.preserveId();
    let pagesId = pdf.preserveId();
    let firstPageId = pdf.preserveId();
    let contentId = pdf.preserveId();
    pdf.addObject({
        Type: '/Catalog',
        Pages: `${pagesId} 0 R`,
    }, false, catlogId);
    pdf.addObject({
        Type: '/Pages',
        Kids: `[${firstPageId} 0 R]`,
        Count: 1
    }, false, pagesId);
    pdf.addObject({
        Type: '/Page',
        Parent: `${pagesId} 0 R`,
        Resources: `<< >>`,
        Contents: `[${contentId} 0 R]`,
        MediaBox: '[0 0 595.27559 841.88976]' //A4 大小
    }, false, firstPageId);

    /**
     * 以下是一個 Content Stream
     * 用 PostScript 畫圖形
     * 理論上 svg 的路徑都可以轉成對應的 PostScript
     */
    pdf.addObject({}, [
        '10 10 m 60 110 l 110 10 l s', //空心三角形
        '130 10 m 180 110 l 230 10 l f', //實心三角形
        '0 0 1 rg 250 10 m 300 110 l 350 10 l f', //實心三角形(藍色)
        '1 0 0 RG 370 10 m 420 110 l 470 10 l s' //空心三角形(紅色)
    ].join(' '), contentId);
    return pdf.toDataUrl(catlogId);
}

謝謝你~~~~~~~~~
能在問一下,我的圖片還是存不進去pdf裡面
可以下載PDF,不過開啟時是空白的。
我從console.log(pdfDataUrl)的網址連進去

開啟時也是空白的,
是我哪邊寫錯了呢?

<body>
    <canvas id="myCanvas"></canvas>
    <button id="myBtn">下載</button>

    <script src="./pdf.js"></script>
    <script>
        var myCanvas = document.getElementById("myCanvas")
        myCanvas.width = 200;
        myCanvas.height = 200;
        var ctx = myCanvas.getContext("2d")
        ctx.fillStyle = "rgb(255,255,255)";
        ctx.fillRect(0, 0, 200, 200);
        ctx.beginPath();
        ctx.arc(100, 100, 50, 0, Math.PI * 2);
        ctx.stroke();

        var jpgData = myCanvas.toDataURL('image/jpeg', 1.0);//jpegBase64

        //把圖片畫到 pdf 上
        function drawToPdf(jpegBase64, opt) {
            let jpegbase85 = jpegBase64.split('base64,')[1] //轉成 base85

            let pdf = new Pdf();
            let catlogId = pdf.preserveId();
            let pagesId = pdf.preserveId();
            let firstPageId = pdf.preserveId();
            let resourceId = pdf.preserveId();
            let imgId = pdf.preserveId();
            let contentId = pdf.preserveId();
            pdf.addObject({
                Type: '/Catalog',
                Pages: `${pagesId} 0 R`,
            }, false, catlogId);
            pdf.addObject({
                Type: '/Pages',
                Kids: `[${firstPageId} 0 R]`,
                Count: 1
            }, false, pagesId);
            pdf.addObject({
                Type: '/Page',
                Parent: `${pagesId} 0 R`,
                Resources: `${resourceId} 0 R`,
                Contents: `[${contentId} 0 R]`,
                MediaBox: '[0 0 595.27559 841.88976]' //A4 大小
            }, false, firstPageId);
            pdf.addObject({
                ProcSet: '[/PDF /ImageB ]',
                XObject: `<< /Im1 ${imgId} 0 R >>`
            }, false, resourceId);

            pdf.addObject({
                Type: '/XObject',
                Subtype: '/Image',
                Width: opt.width,
                Height: opt.height,
                ColorSpace: '/DeviceRGB',
                BitsPerComponent: '8',
                Filter: '[/ASCII85Decode /DCTDecode]'
            }, jpegbase85+'~>', imgId);
            pdf.addObject({}, `${opt.width} 0 0 ${opt.height} ${opt.x} ${opt.y} cm /Im1 Do`, contentId);
            return pdf.toDataUrl(catlogId);
            
            //因為想直接用url下載 所以就把function base64ToBase85 刪除了
        }


        const pdfDataUrl = drawToPdf(jpgData, {
            x: 100,
            y: 600,
            width: 200,
            height: 200
        });
        console.log(pdfDataUrl);//之後直接拿這個去下載pdf
        
    </script>
淺水員 iT邦高手 2 級 ‧ 2021-11-28 23:27:24 檢舉

應該用 base85 的地方卻用 base64 當然會出錯
原先的 function base64ToBase85 放在那邊並不妨礙回傳值是 data url
可以直接放到超連結

淺水員感謝你~~~
可以再請教你兩個問題嗎
因為文章太長了 我再開一個問題
能再麻煩你嗎?感謝~~

2
海綿寶寶
iT邦大神 1 級 ‧ 2021-11-26 12:10:23

提供兩個參考
1.這個jspdf的範例只有297KB
2.另一個pdfmake,我不知道多大,麻煩自己看

個人覺得
jspdf 的作者如果可以少寫一列程式碼就不會多寫
如果硬要減少檔案大小
你可以去讀jspdf 的原始碼
然後刪掉一些你用不到的功能

看更多先前的回應...收起先前的回應...

嫌太大就自己開發啊?
拿別人的來用還嫌...

要開發就自己看他的原始碼一邊開發
都是open source 沒再怕你看的

我不是很能理解這成本有何損失-.-

搞得把專案縮到1.5MB好像很厲害一樣

fillano iT邦超人 1 級 ‧ 2021-11-30 17:30:23 檢舉

不過看了一下pdf的規格,恐怕最小也就像淺水員的範例這樣了,他這是達到功能的最小規格子集XD,把一個影像放入一頁,結束。

我要發表回答

立即登入回答