// SW픽 로컬 진단 엔진. 파일 내용은 네트워크로 보내지 않습니다. const MAX_FILE=12*1024*1024, MAX_XML=4*1024*1024; export function crc32(data){let c=0xffffffff;for(const b of data){c^=b;for(let j=0;j<8;j++)c=(c>>>1)^((c&1)?0xedb88320:0);}return(c^0xffffffff)>>>0;} export function zipDirectory(bytes){ if(bytes.length>MAX_FILE)throw Error('12MB 이하 파일을 선택하세요.'); const v=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);let end=-1; for(let i=bytes.length-22;i>=Math.max(0,bytes.length-65557);i--){if(v.getUint32(i,true)===0x06054b50&&i+22+v.getUint16(i+20,true)===bytes.length){end=i;break;}} if(end<0)throw Error('정상 ZIP 기반 문서가 아닙니다. 암호화 파일 또는 이전 DOC/PPT 형식은 지원하지 않습니다.'); const count=v.getUint16(end+10,true),size=v.getUint32(end+12,true),offset=v.getUint32(end+16,true); if(v.getUint16(end+4,true)||v.getUint16(end+6,true)||count===65535||offset===0xffffffff||count>2500||offset+size>end)throw Error('분할 ZIP·ZIP64·매우 큰 문서는 지원하지 않습니다.'); let pos=offset;const entries=[]; for(let i=0;iend||v.getUint32(pos,true)!==0x02014b50)throw Error('ZIP 목록 구조가 손상됐습니다.'); const flags=v.getUint16(pos+8,true),method=v.getUint16(pos+10,true),crc=v.getUint32(pos+16,true),compressed=v.getUint32(pos+20,true),length=v.getUint32(pos+24,true),nl=v.getUint16(pos+28,true),el=v.getUint16(pos+30,true),cl=v.getUint16(pos+32,true),local=v.getUint32(pos+42,true); if(flags&1)throw Error('암호화 ZIP은 검사하지 않습니다.'); if(pos+46+nl+el+cl>end||local+30>offset)throw Error('ZIP 경로 또는 데이터 위치가 올바르지 않습니다.'); const name=new TextDecoder('utf-8').decode(bytes.subarray(pos+46,pos+46+nl)); if(name.includes('\0'))throw Error('올바르지 않은 파일 이름입니다.'); if(v.getUint32(local,true)!==0x04034b50)throw Error('ZIP 파일 헤더를 읽을 수 없습니다.'); const start=local+30+v.getUint16(local+26,true)+v.getUint16(local+28,true); if(start+compressed>offset)throw Error('압축 데이터 범위를 벗어났습니다.'); entries.push({name,method,crc,compressed,length,start});pos+=46+nl+el+cl; } if(pos!==offset+size)throw Error('ZIP 목록 크기가 일치하지 않습니다.'); return entries; } export async function readEntry(bytes,e){ if(e.length>MAX_XML)throw Error('개별 XML이 4MB를 넘어 검사를 중단했습니다.'); const raw=bytes.subarray(e.start,e.start+e.compressed);let result; if(e.method===0)result=raw; else if(e.method===8){ let stream;try{stream=new DecompressionStream('deflate-raw')}catch{throw Error('이 브라우저는 문서 압축 해제를 지원하지 않습니다. 최신 브라우저에서 확인하세요.')} const reader=new Blob([raw]).stream().pipeThrough(stream).getReader();const chunks=[];let total=0; try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>e.length||total>MAX_XML){await reader.cancel();throw Error('선언된 크기보다 많은 압축 데이터입니다.')}chunks.push(value);}} finally{reader.releaseLock();} result=new Uint8Array(total);let p=0;for(const c of chunks){result.set(c,p);p+=c.length;} }else throw Error('이 문서의 압축 방식은 지원하지 않습니다.'); if(result.length!==e.length||crc32(result)!==e.crc)throw Error('문서 내부 파일의 크기 또는 CRC가 맞지 않습니다.'); return new TextDecoder('utf-8',{fatal:true}).decode(result); } export function parseXML(text,Parser=globalThis.DOMParser){ if(/Array.from(d.getElementsByTagNameNS('*',name)); const attr=(el,name)=>el.getAttributeNS('http://schemas.openxmlformats.org/wordprocessingml/2006/main',name)||el.getAttributeNS('http://purl.oclc.org/ooxml/wordprocessingml/main',name)||el.getAttribute('w:'+name)||el.getAttribute(name); export async function inspectOffice(bytes,Parser=globalThis.DOMParser){ const es=zipDirectory(bytes),map=new Map(es.map(e=>[e.name,e])); if(map.size!==es.length)throw Error('중복 경로가 있는 문서는 자동 판정하지 않습니다.'); const kind=map.has('word/document.xml')?'docx':map.has('ppt/presentation.xml')?'pptx':null; if(!kind)throw Error('DOCX 또는 PPTX 파일을 선택하세요.'); if(es.some(e=>/vbaProject\.bin$/i.test(e.name)))throw Error('매크로 문서는 지원하지 않습니다.'); const xml=es.filter(e=>/^(word|ppt)\/.*\.xml$|^docProps\/core\.xml$/.test(e.name)); if(xml.reduce((a,e)=>a+e.length,0)>20*1024*1024)throw Error('XML 합계가 20MB를 넘는 문서는 지원하지 않습니다.'); const docs=new Map();for(const e of xml){docs.set(e.name,parseXML(await readEntry(bytes,e),Parser));} const media=es.filter(e=>/^(word|ppt)\/media\//.test(e.name)&&!e.name.endsWith('/')); const result={kind,entries:es.length,mediaCount:media.length,mediaBytes:media.reduce((s,e)=>s+e.length,0),media:media.sort((a,b)=>b.length-a.length).map(e=>({name:e.name.split('/').pop(),bytes:e.length})),embeddedFonts:es.filter(e=>/^(word|ppt)\/fonts\//.test(e.name)&&!e.name.endsWith('/')).length}; if(kind==='docx'){ let insertions=0,deletions=0,comments=0,hiddenRuns=0;const fontNames=new Set(); for(const [name,d] of docs){if(!name.startsWith('word/'))continue;insertions+=local(d,'ins').length;deletions+=local(d,'del').length;if(/^word\/comments[^/]*\.xml$/.test(name))comments+=local(d,'comment').length;hiddenRuns+=local(d,'vanish').filter(e=>!['0','false','off'].includes(attr(e,'val'))).length;for(const e of local(d,'rFonts'))for(const k of ['ascii','hAnsi','eastAsia','cs']){const n=attr(e,k);if(n)fontNames.add(n)}} const styles=docs.get('word/styles.xml');const outline=new Map(),basedOn=new Map(); if(styles)for(const s of local(styles,'style')){const id=attr(s,'styleId'),o=local(s,'outlineLvl')[0],b=local(s,'basedOn')[0];if(o)outline.set(id,Number(attr(o,'val'))+1);if(b)basedOn.set(id,attr(b,'val'));} function level(id,seen=new Set()){if(seen.has(id))return null;seen.add(id);if(outline.has(id))return outline.get(id);return basedOn.has(id)?level(basedOn.get(id),seen):null;} const main=docs.get('word/document.xml');const headings=[]; for(const p of local(main,'p')){const pp=Array.from(p.childNodes).find(e=>e.localName==='pPr'),sty=pp?local(pp,'pStyle')[0]:null,direct=pp?local(pp,'outlineLvl')[0]:null,l=direct?Number(attr(direct,'val'))+1:sty?level(attr(sty,'val')):null;if(l&&l<=9){const text=local(p,'t').map(t=>t.textContent).join('');headings.push({level:l,text:text.slice(0,160)})}} const jumps=[];for(let i=1;iheadings[i-1].level+1)jumps.push(i+1); const toc=local(main,'instrText').some(n=>/\bTOC\b/.test(n.textContent))||local(main,'fldSimple').some(n=>/\bTOC\b/.test(attr(n,'instr'))); Object.assign(result,{insertions,deletions,comments,hiddenRuns,headings,headingJumps:jumps,tocField:toc,fontNames:[...fontNames].sort()}); }else{ const slides=[...docs].filter(([n])=>/^ppt\/slides\/slide\d+\.xml$/.test(n));const notes=[...docs].filter(([n])=>/^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(n));const fontNames=new Set();for(const[,d]of slides)for(const type of ['latin','ea','cs'])for(const n of local(d,type)){const f=n.getAttribute('typeface');if(f)fontNames.add(f)} const noteCounts=notes.map(([n,d])=>({name:n.split('/').pop(),textCharacters:local(d,'t').map(t=>t.textContent).join('').length})); Object.assign(result,{slides:slides.length,notesWithText:noteCounts.filter(x=>x.textCharacters>0).length,notes:noteCounts,fontNames:[...fontNames].sort(),audioVideo:media.filter(e=>/\.(mp4|mov|wmv|avi|mp3|wav|m4a)$/i.test(e.name)).length}); } return result; } export function parseCSV(text,delimiter=','){ if(text.length>100000)throw Error('텍스트는 10만 자 이하로 입력하세요.');text=text.replace(/^\uFEFF/,'');const rows=[];let row=[],field='',quoted=false,closed=false; for(let i=0;iv!==''))rows.push(row);row=[];field='';closed=false;continue}field+=c; } if(quoted)throw Error('닫히지 않은 따옴표가 있습니다.');row.push(field);if(row.some(v=>v!==''))rows.push(row); if(rows.length<2)throw Error('제목 행과 데이터 행을 함께 입력하세요.');if(rows.length>1001)throw Error('한 번에 데이터 1000행까지 검사합니다.');return rows; } export function inspectCSV(text,delimiter=','){ const rows=parseCSV(text,delimiter),head=rows[0],data=rows.slice(1);const bad=data.map((r,i)=>r.length!==head.length?i+2:null).filter(Boolean); const ids=new Map(),duplicates=[];for(let i=0;i/^(amount|금액|합계|매출)$/i.test(h.trim()));let cents=0,invalidAmounts=[]; if(amountIndex>=0)for(let i=0;i1e10)invalidAmounts.push(i+2);else cents+=Math.round(Number(val)*100)} return {rows:data.length,columns:head.length,badRows:bad,duplicates,emptyIds:data.filter(r=>r[0].trim()==='').length,leadingZeroIds:data.filter(r=>/^0\d+/.test(r[0])).length,amountIndex,total:amountIndex>=0&&invalidAmounts.length===0&&bad.length===0?cents/100:null,invalidAmounts}; } export function compareText(before,after){ if(before.length+after.length>60000)throw Error('두 텍스트 합계 6만 자 이하로 비교하세요.');const split=s=>s.replace(/\r\n?/g,'\n').split('\n');const a=split(before),b=split(after);if(a.length>500||b.length>500)throw Error('각 텍스트는 500줄 이하로 입력하세요.'); const matrix=Array.from({length:a.length+1},()=>new Uint16Array(b.length+1));for(let i=a.length-1;i>=0;i--)for(let j=b.length-1;j>=0;j--)matrix[i][j]=a[i]===b[j]?1+matrix[i+1][j+1]:Math.max(matrix[i+1][j],matrix[i][j+1]); let i=0,j=0;const added=[],removed=[];while(i=matrix[i][j+1])removed.push(a[i++]);else added.push(b[j++])}while(iMAX_FILE)throw Error('12MB 이하 파일을 선택하세요.');if(!globalThis.crypto?.subtle)throw Error('보안 연결을 지원하는 최신 브라우저가 필요합니다.');return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))).map(v=>v.toString(16).padStart(2,'0')).join('')} export function capacity(total,free,pinned,reserve){for(const n of [total,free,pinned,reserve])if(!Number.isFinite(n)||n<0||n>100000)throw Error('0~100000 범위의 GB 값을 입력하세요.');if(pinned>total)throw Error('필요한 오프라인 자료가 전체 자료보다 클 수 없습니다.');const budget=free-reserve;return{budget,mirrorFits:total<=budget,pinnedFits:pinned<=budget,mirrorShortfall:Math.max(0,total-budget),pinnedShortfall:Math.max(0,pinned-budget)}} export function pixelSize(width,height,display,dpi){if(![width,height,display,dpi].every(v=>Number.isFinite(v)&&v>0))throw Error('모든 값은 0보다 커야 합니다.');if(width>30000||height>30000||display>30000||dpi>2400)throw Error('입력 크기가 지원 범위를 넘었습니다.');return{scale:display/width,heightAtDisplay:height*display/width,printWidthMm:width/dpi*25.4,printHeightMm:height/dpi*25.4}} export function permission(roles,externalBlocked=false){const levels={none:0,view:1,comment:2,edit:3,full:4};if(roles.some(r=>!(r in levels)))throw Error('올바른 역할을 선택하세요.');const max=Math.max(0,...roles.map(r=>levels[r]));return{level:max,role:['접근 없음','보기','댓글','편집','전체 권한'][max],needsOwnerReview:externalBlocked,widePath:roles.findIndex(r=>levels[r]===max)}} export function anchorAudit(text){if(text.length>100000)throw Error('HTML은 10만 자 이하로 입력하세요.');const stripped=text.replace(//g,'').replace(/]*>[^]*?<\/script>/gi,'');const tags=(stripped.match(/<[a-zA-Z][^>]*>/g)||[]).join(' ');const ids=[...tags.matchAll(/\sid\s*=\s*(["'])(.*?)\1/gi)].map(m=>m[2]);const links=[...tags.matchAll(/\shref\s*=\s*(["'])#(.*?)\1/gi)].map(m=>m[2]).filter(Boolean);const dup=[...new Set(ids.filter((x,i)=>ids.indexOf(x)!==i))];const missing=links.filter(x=>{try{return !ids.includes(decodeURIComponent(x))}catch{return true}});return{ids:ids.length,links:links.length,duplicates:dup,missing:[...new Set(missing)]}} export function template(project,owner){const clean=s=>s.trim().replace(/[\r\n<>]/g,' ').slice(0,80);project=clean(project);owner=clean(owner);if(!project)throw Error('프로젝트 이름을 입력하세요.');return '# '+project+'\n\n## 목적\n\n## 입력 자료\n\n## 결정 사항\n\n## 후속 업무\n- [ ] 담당: '+(owner||'미정')+' / 기한: / 완료 조건:\n\n## 변경 기록\n'} export function folderNames(project,channel,status){const clean=s=>s.trim().normalize('NFC').replace(/[<>:"/\\|?*\x00-\x1f]/g,'-').replace(/\.+$/g,'').slice(0,60);project=clean(project);channel=clean(channel);if(!project||!channel)throw Error('프로젝트와 채널을 입력하세요.');const code={work:'01작업',review:'02검토',final:'03최종'}[status];if(!code)throw Error('작업 상태를 선택하세요.');return{folder:project+'/'+code,filename:project+'-'+channel+'-'+code.slice(2),master:project+'/MASTER'}} export function compareCSV(before,after,delimiter=','){ const a=inspectCSV(before,delimiter),b=inspectCSV(after,delimiter),ar=parseCSV(before,delimiter),br=parseCSV(after,delimiter); if(a.badRows.length||b.badRows.length||a.duplicates.length||b.duplicates.length||a.emptyIds||b.emptyIds)throw Error('열 수 불일치·중복 ID·빈 ID를 먼저 고친 뒤 두 표를 비교하세요.'); if(JSON.stringify(ar[0])!==JSON.stringify(br[0]))throw Error('두 표의 제목 행과 열 순서를 맞춰주세요.'); const am=new Map(ar.slice(1).map(r=>[r[0],r])),bm=new Map(br.slice(1).map(r=>[r[0],r])); return {...b,beforeTotal:a.total,changedIds:[...bm].filter(([id,r])=>am.has(id)&&JSON.stringify(r)!==JSON.stringify(am.get(id))).map(([id])=>id),addedIds:[...bm.keys()].filter(k=>!am.has(k)),removedIds:[...am.keys()].filter(k=>!bm.has(k))}; }