Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537
果網(wǎng)站出現(xiàn)問題,常常需要截圖來提交反饋,這個(gè)功能很實(shí)用。使用HTML5的Canvas可以實(shí)現(xiàn)這個(gè)目標(biāo)。
我們首先引入html2canvas.js,建立一個(gè)簡單的頁面,代碼如下:
然后調(diào)用如下的代碼,上述DIV中包含的內(nèi)容將被截屏并添加到頁面下面:
使用限制:
不能截取插件(Flash、Silverlight等)內(nèi)容和iframe內(nèi)容。
之前在做 html 內(nèi)容導(dǎo)出為 pdf、圖片時(shí),先是用 html2canvas 生成截屏,再進(jìn)一步轉(zhuǎn)換為 pdf 文件,感興趣的同學(xué)可以看下這篇一文搞定前端 html 內(nèi)容轉(zhuǎn)圖片、pdf 和 word 等文件,截圖得到的圖片內(nèi)容、質(zhì)量都沒有什么問題。
不過最近有個(gè)同事反應(yīng),他導(dǎo)出的圖片有 bug,這我倒挺好奇的,因?yàn)檫@個(gè)導(dǎo)出功能已經(jīng)用了很久,并沒有人反饋過有問題(除了那個(gè) pdf 翻頁內(nèi)容被截?cái)?/span>的問題,求助 jym :前端有好的解決方法嗎?),于是我要了他的文檔,果不其然,出現(xiàn)了下面紅框所示的問題。
檢查一下它的 DOM 結(jié)構(gòu),發(fā)現(xiàn)是下面這樣,猜測是就是這個(gè)原因?qū)е碌摹?/span>
為了驗(yàn)證自己的猜想,淺淺調(diào)試一下 html2canvas 的源碼,看下 html2canvas 是怎樣一個(gè)流程,它是如何將 html內(nèi)轉(zhuǎn)成 canvas 的。
在 html2canvas 執(zhí)行的地方打個(gè)斷點(diǎn),開始調(diào)試代碼:
進(jìn)入 html2canvas 內(nèi)部,可以看到內(nèi)部執(zhí)行的是 renderElement 方法:
咱們直接進(jìn)入到 renderElement 方法內(nèi)部,看下它的執(zhí)行流程:
這里主要判斷節(jié)點(diǎn),快速跳過,繼續(xù)執(zhí)行 。
將用戶傳入的 options 與默認(rèn)的 options 合并
構(gòu)建配置項(xiàng),將傳入的 opts 與默認(rèn)配置合并,同時(shí)初始化一個(gè) context 上下文對象(緩存、日志等):
其中 cache 為緩存對象,主要是避免資源重復(fù)加載的問題。
原理如下:
如果遇到圖片鏈接為 blob,在加載完成后,會添加到緩存 _cache 中:
下次使用直接通過 this._cache[src] 從緩存中獲取,不用再發(fā)送請求:
同時(shí),cache 中控制圖片的加載和處理,包括使用 proxy 代理和使用 cors 跨域資源共享這兩種情況資源的處理。
繼續(xù)往下執(zhí)行
使用 DocumentCloner 方法克隆原始 DOM,避免修改原始 DOM。
使用 clonedReferenceElement 將原始 DOM 進(jìn)行克隆,并調(diào)用 toIFrame 將克隆到的 DOM 繪制到 iframe 中進(jìn)行渲染,此時(shí)在 DOM 樹中會出現(xiàn) class 為 html2canvas-container 的 iframe 節(jié)點(diǎn),通過 window.getComputedStyle 就可以拿到要克隆的目標(biāo)節(jié)點(diǎn)上所有的樣式了。
前面幾步很簡單,主要是對傳入的 DOM 元素進(jìn)行解析,獲取目標(biāo)節(jié)點(diǎn)的樣式和內(nèi)容。重點(diǎn)是 toCanvas 即將 DOM 渲染為 canvas 的過程,html2canvas 提供了兩種繪制 canvas 的方式:
咱們接著執(zhí)行,當(dāng)代碼執(zhí)行到這里時(shí)判斷是否使用 foreignObject 的方式生成 canvas:
首先了解下 foreignObject 是什么?
弄懂 foreignObject 后,我們嘗試將 foreignObjectRendering 設(shè)置為 true,看看它是如何生成 canvas 的:
js復(fù)制代碼Html2canvas(warp, {
useCORS: true,
foreignObjectRendering: true,
})
在此處打個(gè)斷點(diǎn):
進(jìn)入 ForeignObjectRenderer 類中
這里通過 ForeignObjectRenderer 實(shí)例化一個(gè) renderer 渲染器實(shí)例,在 ForeignObjectRenderer 構(gòu)造方法中初始化 this.canvas 對象及其上下文 this.ctx
調(diào)用 render 生成 canvas,進(jìn)入到 render 方法:
render 方法執(zhí)行很簡單,首先通過 createForeignObjectSVG 將 DOM 內(nèi)容包裝到<foreignObject>中生成 svg:
生成的 svg 如下所示:
接著通過。loadSerializedSVG 將上面的 SVG 序列化成 img 的 src(SVG 直接內(nèi)聯(lián)),調(diào)用this.ctx.drawImage(img, ...); 將圖片繪制到 this.canvas 上,返回生成好的 canvas 即可。
接著點(diǎn)擊下一步,直到回到最開始的斷點(diǎn)處,將生成好的 canvas 掛在到 DOM 上,如下:
js
復(fù)制代碼document.body.appendChild(canvas)
這就解決了???收工!!!
NoNoNo,為什么使用純 canvas 繪制就有問題呢? 作為 bug 終結(jié)者,問題必須找出來,干就完了 。
而且使用 foreignObject 渲染還有其他問題,我們后面再說。
要想使用純 canvas 方式繪制,那么就需要將 DOM 樹轉(zhuǎn)換為 canvas 可以識別的數(shù)據(jù)類型,html2canvas 使用 parseTree 方法來實(shí)現(xiàn)轉(zhuǎn)換,我們來看下它的執(zhí)行過程。
直接在調(diào)用 parseTree 方法處打斷點(diǎn),進(jìn)入到 parseTree 方法內(nèi):
parseTree 的作用是將克隆 DOM 轉(zhuǎn)換為 ElementContainer 樹。
首先將根節(jié)點(diǎn)轉(zhuǎn)換為 ElementContainer 對象,接著再調(diào)用 parseNodeTree 遍歷根節(jié)點(diǎn)下的每一個(gè)節(jié)點(diǎn),轉(zhuǎn)換為 ElementContainer 對象。
ElementContainer 對象主要包含 DOM 元素的信息:
ts復(fù)制代碼type TextContainer = {
// 文本內(nèi)容
text: string;
// 位置和大小信息
textBounds: TextBounds[];
}
export class ElementContainer {
// 樣式數(shù)據(jù)
readonly styles: CSSParsedDeclaration;
// 當(dāng)前節(jié)點(diǎn)下的文本節(jié)點(diǎn)
readonly textNodes: TextContainer[] = [];
// 除文本節(jié)點(diǎn)外的子元素
readonly elements: ElementContainer[] = [];
// 位置大小信息(寬/高、橫/縱坐標(biāo))
bounds: Bounds;
// 標(biāo)志位,用來決定如何渲染的標(biāo)志
flags = 0;
...
}
ElementContainer 對象是一顆樹狀結(jié)構(gòu),層層遞歸,每個(gè)節(jié)點(diǎn)都包含以上字段,形成一顆 ElementContainer 樹,如下:
繼續(xù)下一步
通過 CanvasRenderer 創(chuàng)建一個(gè)渲染器 renderer,創(chuàng)建 this.canvas和this.ctx上下文對象與 ForeignObjectRenderer 類似
得到渲染器后,調(diào)用 render 方法將 parseTree 生成的 ElementContainer 樹渲染成 canvas,在這里就與 ForeignObjectRenderer 的 render 方法產(chǎn)生差別了。
概念不懂就看 MDN:層疊上下文
首先我們都知道 CSS 是流式布局,也就是在沒有浮動(float)和定位(position)的影響下,是不會發(fā)生重疊的,從上到下、由外到內(nèi)按照 DOM 樹去布局。
而浮動和定位的元素會脫離文檔流,形成一個(gè)層疊上下文,所以如果想正常渲染,就需要得到它們的層疊信息。
可以想象一下:在我們的視線與網(wǎng)頁之間有一條看不見的 z 軸,層疊上下文就是一塊塊薄層,而這些薄層中有很多 DOM 元素,這些薄層根據(jù)層疊信息在這個(gè) z 軸上排列,最終形成了我們看到的絢麗多彩的頁面。
畫個(gè)圖好像更形象些:
白色為正常元素,黃色為 float 元素,藍(lán)色為 position 元素
更多詳細(xì)資料請閱讀:深入理解 CSS 中的層疊上下文和層疊順序
canvas 在繪制節(jié)點(diǎn)時(shí)需要先計(jì)算出整個(gè)目標(biāo)節(jié)點(diǎn)里子節(jié)點(diǎn)渲染時(shí)所展現(xiàn)的不同層級,因?yàn)?Canvas 繪圖需要根據(jù)樣式計(jì)算哪些元素應(yīng)該繪制在上層,哪些在下層。元素在瀏覽器中渲染時(shí),根據(jù) W3C 的標(biāo)準(zhǔn),所有的節(jié)點(diǎn)層級布局,需要遵循層疊上下文和層疊順序的標(biāo)準(zhǔn)。
調(diào)用 parseStackingContexts 方法將 parseTree 生成的 ElementContainer 樹轉(zhuǎn)為層疊上下文。
ElementContainer 樹中的每一個(gè) ElementContainer 節(jié)點(diǎn)都會產(chǎn)生一個(gè) ElementPaint 對象,最終生成層疊上下文的 StackingContext 如下:
數(shù)據(jù)結(jié)構(gòu)如下:
ts復(fù)制代碼// ElementPaint 數(shù)據(jù)結(jié)構(gòu)如下
ElementPaint: {
// 當(dāng)前元素的container
container: ElementContainer
// 當(dāng)前元素的border信息
curves: BoundCurves
}
// StackingContext 數(shù)據(jù)結(jié)構(gòu)如下
{
element: ElementPaint;
// z-index為負(fù)的元素行測會給你的層疊上下文
negativeZIndex: StackingContext[];
// z-index為零或auto、transform或者opacity元素形成的層疊上下文
zeroOrAutoZIndexOrTransformedOrOpacity: StackingContext[];
// 定位或z-index大于等于1的元素形成的層疊上下文
positiveZIndex: StackingContext[];
// 非定位的浮動元素形成的層疊上下文
nonPositionedFloats: StackingContext[];
// 內(nèi)聯(lián)的非定位元素形成的層疊上下文
nonPositionedInlineLevel: StackingContext[];
// 內(nèi)聯(lián)元素
inlineLevel: ElementPaint[];
// 非內(nèi)聯(lián)元素
nonInlineLevel: ElementPaint[];
}
渲染層疊內(nèi)容時(shí)會根據(jù) StackingContext 來決定渲染的順序。
繼續(xù)下一步,調(diào)用 renderStack 方法,renderStack 執(zhí)行 renderStackContent 方法,咱們直接進(jìn)入 renderStackContent 內(nèi):
canvas 繪制時(shí)遵循 w3c 規(guī)定的渲染規(guī)則 painting-order,renderStackContent 方法就是對此規(guī)則的一個(gè)代碼實(shí)現(xiàn),步驟如下:
此處的步驟 1-7 對應(yīng)上圖代碼中的 1-7:
可以看到遍歷時(shí)會對形成層疊上下文的子元素遞歸調(diào)用 renderStack,最終達(dá)到對整個(gè)層疊上下文樹進(jìn)行遞歸的目的:
而對于未形成層疊上下文的子元素,就直接調(diào)用 renderNode 或 renderNodeContent 這兩個(gè)方法,兩者對比,renderNode 多了一層渲染節(jié)點(diǎn)的背景色和邊框的方法(renderNode 函數(shù)內(nèi)部調(diào)用 renderNodeBackgroundAndBorders 和 renderNodeContent 方法)。
renderNodeContent 用于渲染一個(gè)元素節(jié)點(diǎn)里面的內(nèi)容,分為八種類型:純文本、圖片、canvas、svg、iframe、checkbox 和 radio、input、li 和 ol。
除了 iframe 的繪制比較特殊:重新生成渲染器實(shí)例,調(diào)用 render 方法重新繪制,其他的繪制都是調(diào)用 canvas 的一些 API 來實(shí)現(xiàn),比如繪制文字主要用 fillText 方法、繪制圖片、canvas、svg 都是調(diào)用 drawImage 方法進(jìn)行繪制。
所有可能用到的 API
最終繪制到 this.canvas 上返回,至此,html2canvas 的調(diào)試就結(jié)束了。
ok,當(dāng)調(diào)試了一遍 html2canvas 的流程之后,再回到我們的問題上,很顯然就是 canvas 渲染的時(shí)候的問題,也就是 renderNodeContent 方法,那我們直接在這里打個(gè)斷點(diǎn)進(jìn)行調(diào)試(為了方便我只輸入一行文字進(jìn)行調(diào)試),只有當(dāng)是文本節(jié)點(diǎn)時(shí)會進(jìn)入到此斷點(diǎn),等到 mark 標(biāo)簽中對應(yīng)的元素進(jìn)入斷點(diǎn)時(shí),查看:
可以看到此時(shí) width 和 height 已經(jīng)是父節(jié)點(diǎn)的寬高,果真如此 。
既然已經(jīng)知道了問題所在,那么我們開始解決問題,有以下兩種解決方案可供參考:
在 html2canvas 配置中設(shè)置 foreignObjectRendering 為 true,此問題就可以解決嗎?
然而現(xiàn)實(shí)并沒有這么簡單,這樣又會引出新的問題:導(dǎo)出的圖片內(nèi)容丟失
這是為什么呢?
通過 W3C 對SVG 的介紹可知:SVG 不允許連接外部的資源,比如 HTML 中圖片鏈接、CSS link 方式的資源鏈接等,在 SVG 中都會有限制。
解決方法:需要將圖片資源轉(zhuǎn)為 base64,然后再去生成截圖,foreighnObject 這種方法更適合截取內(nèi)容為文字內(nèi)容居多的場景。
在對內(nèi)聯(lián)元素進(jìn)行截?cái)嗲埃?/span>如何確定 p 標(biāo)簽中的 mark 標(biāo)簽有沒有換行? 因?yàn)槲覀儧]必要對所有內(nèi)聯(lián)標(biāo)簽做處理。
如果 mark 標(biāo)簽的高度超過 p 標(biāo)簽的一半時(shí),就說明已經(jīng)換行了,然后將 <mark>要求一</mark> 替換為 <mark>要</mark><mark>求</mark><mark>一</mark> 即可,代碼如下:
ts復(fù)制代碼const handleMarkTag = (ele: HTMLElement) => {
const markElements = ele.querySelectorAll('mark')
for (let sel of markElements) {
const { height } = sel.getBoundingClientRect()
let parentElement = sel.parentElement
while (parentElement?.tagName !== 'P') {
parentElement = parentElement?.parentElement!
}
const { height: parentHeight } = (
parentElement as unknown as HTMLElement
).getBoundingClientRect()
// mark的高度沒有超過p標(biāo)簽的一半時(shí) 則沒有換行
if (height < parentHeight / 2) continue
// 超過一半時(shí)說明換行了
const innerText = sel.innerText
const outHtml = sel.outerHTML
let newHtml = ''
innerText.split('')?.forEach((text) => {
newHtml += outHtml.replace(innerText, text)
})
sel.outerHTML = newHtml
}
}
ok,再次嘗試一下,完美解決,這下可以收工了。
通過對一個(gè)不是 bug 的 bug 的分析,嘗試調(diào)試了一遍 html2canvas 的代碼,弄懂了瀏覽器截圖的原理及 html2canvas 的核心流程,并從中學(xué)到了幾點(diǎn)新知識:
發(fā)現(xiàn) canvas 真是一個(gè)有趣的東西,什么都能畫,像我現(xiàn)在用于畫圖的工具excalidraw、圖表庫g6、g2、echarts都是用的 canvas 搞的,看來得抽時(shí)間學(xué)習(xí)一下 canvas,不要等到“書到用時(shí)方恨少“。
以上就是本文的全部內(nèi)容,希望這篇文章對你有所幫助,歡迎點(diǎn)贊和收藏 ,如果發(fā)現(xiàn)有什么錯(cuò)誤或者更好的解決方案及建議,歡迎隨時(shí)聯(lián)系。
作者:翔子丶 鏈接:https://juejin.cn/post/7277045020423798840 來源:稀土掘金 著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請注明出處。
. 項(xiàng)目要求需要用js實(shí)現(xiàn)photoshop中鋼筆摳圖功能,就用了近三四天的時(shí)間去解決它,最終還是基本上把他實(shí)現(xiàn)了。
做的過程中走了不少彎路,最終一同事找到了canvans以比較核心的屬性globalCompositeOperation = "destination-out",
屬性可以實(shí)現(xiàn)通過由多個(gè)點(diǎn)構(gòu)成的閉合區(qū)間設(shè)置成透明色穿透畫布背景色或是背景圖片,這樣省了許多事。
2.實(shí)現(xiàn)效果:
鼠標(biāo)點(diǎn)完之后會將所有的點(diǎn)連成閉合區(qū)間,并可自由拖拉任一點(diǎn),當(dāng)形成閉合區(qū)間后,可在任意兩點(diǎn)之間添加新點(diǎn)進(jìn)行拖拉。
3.實(shí)現(xiàn)思路:
設(shè)置兩層div,底層設(shè)置圖片,頂層設(shè)置canvas畫布(如果將圖片渲染到畫布上,摳圖時(shí)會閃爍,所以至于底層),在畫布上監(jiān)視
鼠標(biāo)事件反復(fù)渲染點(diǎn)及之間連線,形成閉合區(qū)間后將整體畫布渲染小塊背景圖片,并將閉合區(qū)間渲染透明色。并把點(diǎn)的相對畫布
坐標(biāo)記錄或更新到數(shù)組中去。截完圖后,將點(diǎn)的坐標(biāo)集合傳回后臺,由后臺代碼實(shí)現(xiàn)根據(jù)坐標(biāo)點(diǎn)及圖片寬度高度實(shí)現(xiàn)截圖,并設(shè)
至背景色為透明色(canvas也可以實(shí)現(xiàn)截圖,但需要處理像素點(diǎn)實(shí)現(xiàn)背景透明,暫時(shí)還沒實(shí)現(xiàn),計(jì)劃用C#后臺代碼實(shí)現(xiàn))。
4、js
這是其中的js代碼
5.總結(jié):
不足:當(dāng)光標(biāo)移動到線上時(shí),判斷一點(diǎn)是否在兩點(diǎn)連成的直線上計(jì)算方法不正確,應(yīng)該計(jì)算為一點(diǎn)是否在兩點(diǎn)圓兩條外切線所圍成的矩形
內(nèi);鋼筆點(diǎn)應(yīng)為替換為小的div方格比較合理,像下面的矩形摳圖;(思路:將存取的點(diǎn)坐標(biāo)集合和動態(tài)添加的小div方格建立對應(yīng)關(guān)系
當(dāng)拖動小方格時(shí),觸發(fā)事件更新坐標(biāo)點(diǎn)集合,并重新渲染)。
6.這只是js鋼筆摳圖的一種解決方案,項(xiàng)目中現(xiàn)在這塊還在改進(jìn),如果大家有好的方法或是資料的話,希望能分享一下。謝謝
想要增加IT技能或轉(zhuǎn)行IT行業(yè)的小伙伴們,可以千鋒重慶IT培訓(xùn)班來看看。千鋒重慶IT培訓(xùn)機(jī)構(gòu)100%面授課程,講師專業(yè)的教學(xué),讓你半年快速掌握專業(yè)的知識,實(shí)現(xiàn)高薪就業(yè)。
*請認(rèn)真填寫需求信息,我們會在24小時(shí)內(nèi)與您取得聯(lián)系。