1+ // SPDX-License-Identifier: MIT
2+ pragma solidity 0.8.24 ;
3+
4+ import {Base64} from "solady/utils/Base64.sol " ;
5+ import {LibString} from "solady/utils/LibString.sol " ;
6+ import {Ethscriptions} from "../Ethscriptions.sol " ;
7+
8+ /// @title EthscriptionsRendererLib
9+ /// @notice Library for rendering Ethscription metadata and media URIs
10+ /// @dev Contains all token URI generation, media handling, and metadata formatting logic
11+ library EthscriptionsRendererLib {
12+ using LibString for * ;
13+
14+ /// @notice Build attributes JSON array from ethscription data
15+ /// @param etsc Storage pointer to the ethscription
16+ /// @param txHash The transaction hash of the ethscription
17+ /// @return JSON string of attributes array
18+ function buildAttributes (Ethscriptions.Ethscription storage etsc , bytes32 txHash )
19+ internal
20+ view
21+ returns (string memory )
22+ {
23+ // Build in chunks to avoid stack too deep
24+ string memory part1 = string .concat (
25+ '[{"trait_type":"Transaction Hash","value":" ' ,
26+ uint256 (txHash).toHexString (),
27+ '"},{"trait_type":"Ethscription Number","display_type":"number","value": ' ,
28+ etsc.ethscriptionNumber.toString (),
29+ '},{"trait_type":"Creator","value":" ' ,
30+ etsc.creator.toHexString (),
31+ '"},{"trait_type":"Initial Owner","value":" ' ,
32+ etsc.initialOwner.toHexString ()
33+ );
34+
35+ string memory part2 = string .concat (
36+ '"},{"trait_type":"Content SHA","value":" ' ,
37+ uint256 (etsc.content.contentSha).toHexString (),
38+ '"},{"trait_type":"MIME Type","value":" ' ,
39+ etsc.content.mimetype.escapeJSON (),
40+ '"},{"trait_type":"Media Type","value":" ' ,
41+ etsc.content.mediaType.escapeJSON (),
42+ '"},{"trait_type":"MIME Subtype","value":" ' ,
43+ etsc.content.mimeSubtype.escapeJSON ()
44+ );
45+
46+ string memory part3 = string .concat (
47+ '"},{"trait_type":"ESIP-6","value":" ' ,
48+ etsc.content.esip6 ? "true " : "false " ,
49+ '"},{"trait_type":"L1 Block Number","display_type":"number","value": ' ,
50+ uint256 (etsc.l1BlockNumber).toString (),
51+ '},{"trait_type":"L2 Block Number","display_type":"number","value": ' ,
52+ uint256 (etsc.l2BlockNumber).toString (),
53+ '},{"trait_type":"Created At","display_type":"date","value": ' ,
54+ etsc.createdAt.toString (),
55+ '}] '
56+ );
57+
58+ return string .concat (part1, part2, part3);
59+ }
60+
61+ /// @notice Generate the media URI for an ethscription
62+ /// @param etsc Storage pointer to the ethscription
63+ /// @param content The content bytes
64+ /// @return mediaType Either "image" or "animation_url"
65+ /// @return mediaUri The data URI for the media
66+ function getMediaUri (Ethscriptions.Ethscription storage etsc , bytes memory content )
67+ internal
68+ view
69+ returns (string memory mediaType , string memory mediaUri )
70+ {
71+ if (etsc.content.mimetype.startsWith ("image/ " )) {
72+ // Image content: wrap in SVG for pixel-perfect rendering
73+ string memory imageDataUri = constructDataURI (etsc.content.mimetype, content);
74+ string memory svg = wrapImageInSVG (imageDataUri);
75+ mediaUri = constructDataURI ("image/svg+xml " , bytes (svg));
76+ return ("image " , mediaUri);
77+ } else {
78+ // Non-image content: use animation_url
79+ if (etsc.content.mimetype.startsWith ("video/ " ) ||
80+ etsc.content.mimetype.startsWith ("audio/ " ) ||
81+ etsc.content.mimetype.eq ("text/html " )) {
82+ // Video, audio, and HTML pass through directly as data URIs
83+ mediaUri = constructDataURI (etsc.content.mimetype, content);
84+ } else {
85+ // Everything else (text/plain, application/json, etc.) uses the HTML viewer
86+ mediaUri = createTextViewerDataURI (etsc.content.mimetype, content);
87+ }
88+ return ("animation_url " , mediaUri);
89+ }
90+ }
91+
92+ /// @notice Build complete token URI JSON
93+ /// @param etsc Storage pointer to the ethscription
94+ /// @param txHash The transaction hash of the ethscription
95+ /// @param content The content bytes
96+ /// @return The complete base64-encoded data URI
97+ function buildTokenURI (
98+ Ethscriptions.Ethscription storage etsc ,
99+ bytes32 txHash ,
100+ bytes memory content
101+ ) internal view returns (string memory ) {
102+ // Get media URI
103+ (string memory mediaType , string memory mediaUri ) = getMediaUri (etsc, content);
104+
105+ // Build attributes
106+ string memory attributes = buildAttributes (etsc, txHash);
107+
108+ // Build JSON
109+ string memory json = string .concat (
110+ '{"name":"Ethscription # ' ,
111+ etsc.ethscriptionNumber.toString (),
112+ '","description":"Ethscription # ' ,
113+ etsc.ethscriptionNumber.toString (),
114+ ' created by ' ,
115+ etsc.creator.toHexString (),
116+ '"," ' ,
117+ mediaType,
118+ '":" ' ,
119+ mediaUri.escapeJSON (),
120+ '","attributes": ' ,
121+ attributes,
122+ '} '
123+ );
124+
125+ return string .concat (
126+ "data:application/json;base64, " ,
127+ Base64.encode (bytes (json))
128+ );
129+ }
130+
131+ /// @notice Construct a base64-encoded data URI
132+ /// @param mimetype The MIME type
133+ /// @param content The content bytes
134+ /// @return The complete data URI
135+ function constructDataURI (string memory mimetype , bytes memory content )
136+ internal
137+ pure
138+ returns (string memory )
139+ {
140+ return string .concat (
141+ "data: " ,
142+ mimetype,
143+ ";base64, " ,
144+ Base64.encode (content)
145+ );
146+ }
147+
148+ /// @notice Wrap an image in SVG for pixel-perfect rendering
149+ /// @param imageDataUri The image data URI to wrap
150+ /// @return The SVG markup
151+ function wrapImageInSVG (string memory imageDataUri )
152+ internal
153+ pure
154+ returns (string memory )
155+ {
156+ // SVG wrapper that enforces pixelated/nearest-neighbor scaling for pixel art
157+ return string .concat (
158+ '<svg width="1200" height="1200" viewBox="0 0 1200 1200" version="1.2" xmlns="http://www.w3.org/2000/svg" style="background-image:url( ' ,
159+ imageDataUri,
160+ ');background-repeat:no-repeat;background-size:contain;background-position:center;image-rendering:-webkit-optimize-contrast;image-rendering:-moz-crisp-edges;image-rendering:pixelated;"></svg> '
161+ );
162+ }
163+
164+ /// @notice Create an HTML viewer data URI for text content
165+ /// @param mimetype The MIME type of the content
166+ /// @param content The content bytes
167+ /// @return The HTML viewer data URI
168+ function createTextViewerDataURI (string memory mimetype , bytes memory content )
169+ internal
170+ pure
171+ returns (string memory )
172+ {
173+ // Base64 encode the content for embedding in HTML
174+ string memory encodedContent = Base64.encode (content);
175+
176+ // Generate HTML with embedded content
177+ string memory html = generateTextViewerHTML (encodedContent, mimetype);
178+
179+ // Return as base64-encoded HTML data URI
180+ return constructDataURI ("text/html " , bytes (html));
181+ }
182+
183+ /// @notice Generate minimal HTML viewer for text content
184+ /// @param encodedPayload Base64-encoded content
185+ /// @param mimetype The MIME type
186+ /// @return The complete HTML string
187+ function generateTextViewerHTML (string memory encodedPayload , string memory mimetype )
188+ internal
189+ pure
190+ returns (string memory )
191+ {
192+ // Ultra-minimal HTML with inline styles optimized for iframe display
193+ return string .concat (
194+ '<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/> ' ,
195+ '<style>*{box-sizing:border-box;margin:0;padding:0;border:0}body{padding:6dvw;background:#0b0b0c;color:#f5f5f5;font-family:monospace;display:flex;justify-content:center;align-items:center;min-height:100dvh;overflow:hidden} ' ,
196+ 'pre{white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;line-height:1.4;font-size:14px}</style></head> ' ,
197+ '<body><pre id="o"></pre><script> ' ,
198+ 'const p=" ' , encodedPayload, '"; ' ,
199+ 'const m=" ' , mimetype.escapeJSON (), '"; ' ,
200+ 'function d(b){try{return decodeURIComponent(atob(b).split("").map(c=>"%"+("00"+c.charCodeAt(0).toString(16)).slice(-2)).join(""))}catch{return null}} ' ,
201+ 'const r=d(p);let t=""; ' ,
202+ 'if(r!==null){t=r;try{const j=JSON.parse(r);t=JSON.stringify(j,null,2)}catch{}} ' ,
203+ 'else{t="data:"+m+";base64,"+p} ' ,
204+ 'document.getElementById("o").textContent=t||"(empty)"; ' ,
205+ '</script></body></html> '
206+ );
207+ }
208+ }
0 commit comments