main.go

  1package main
  2
  3import (
  4	"bytes"
  5	"embed"
  6	"fmt"
  7	"image"
  8	"image/color"
  9	"image/png"
 10	"io/ioutil"
 11	"os"
 12	"strings"
 13
 14	"gioui.org/font/opentype"
 15	"gioui.org/gpu/headless"
 16	"gioui.org/io/router"
 17	"gioui.org/layout"
 18	"gioui.org/op"
 19	"gioui.org/op/clip"
 20	"gioui.org/op/paint"
 21	"gioui.org/text"
 22	"gioui.org/unit"
 23	"gioui.org/widget/material"
 24	"github.com/adrg/frontmatter"
 25	"github.com/go-git/go-git/v5"
 26	flag "github.com/spf13/pflag"
 27	"gopkg.in/yaml.v3"
 28)
 29
 30var (
 31	flagHelp          *bool   = flag.BoolP("help", "h", false, "Show the help message")
 32	flagInput         *string = flag.StringP("input", "i", "", "Path to input Markdown")
 33	flagOutput        *string = flag.StringP("output", "o", "", "Path to output PNG")
 34	flagMetaSize      *int    = flag.IntP("metasize", "m", 40, "Size of font for meta information")
 35	flagPostTitleSize *int    = flag.IntP("posttitlesize", "p", 60, "Size of font for post title")
 36	flagSiteTitleSize *int    = flag.IntP("sitetitlesize", "s", 50, "Size of font for site title")
 37)
 38
 39//go:embed fonts
 40var fontEmbed embed.FS
 41
 42func main() {
 43	flag.Parse()
 44
 45	if *flagHelp {
 46		help()
 47		os.Exit(0)
 48	}
 49
 50	validateFlags()
 51
 52	postTitle, postDate, postContent := getPostInfo(*flagInput)
 53	postReadTime := getReadTime(postContent)
 54	siteTitle := getSiteTitle()
 55	dateEdited := getGitDate(*flagInput)
 56
 57	collection := fontCollection()
 58
 59	const scale = 1
 60	sz := image.Point{X: 1200 * scale, Y: 630 * scale}
 61
 62	w, err := headless.NewWindow(sz.X, sz.Y)
 63	if err != nil {
 64		panic(err)
 65	}
 66	th := material.NewTheme(collection)
 67	gtx := layout.Context{
 68		Ops:   new(op.Ops),
 69		Queue: new(router.Router),
 70		Metric: unit.Metric{
 71			PxPerDp: scale,
 72			PxPerSp: scale,
 73		},
 74	}
 75	layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
 76		gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .6)
 77		gtx.Constraints.Min.X = gtx.Constraints.Max.X
 78		gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
 79
 80		return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle, Spacing: layout.SpaceAround}.Layout(gtx,
 81
 82			layout.Rigid(layout.Spacer{Height: 50}.Layout),
 83
 84			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 85				gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .9)
 86				gtx.Constraints.Min.X = gtx.Constraints.Max.X
 87				title := material.Label(th, unit.Sp(float32(*flagPostTitleSize)), postTitle)
 88				title.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
 89				title.Alignment = text.Middle
 90				return title.Layout(gtx)
 91			}),
 92
 93			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 94				gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .7)
 95				gtx.Constraints.Min.X = gtx.Constraints.Max.X
 96				return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
 97					layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 98						return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
 99							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
100								rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Reading Time: ")
101								rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
102								rTime.Alignment = text.Start
103								return rTime.Layout(gtx)
104							}),
105							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
106								rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(postReadTime, " minutes"))
107								rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
108								rTime.Alignment = text.End
109								return rTime.Layout(gtx)
110							}),
111						)
112					}),
113
114					layout.Rigid(func(gtx layout.Context) layout.Dimensions {
115						return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
116							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
117								pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Published: ")
118								pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
119								pDate.Alignment = text.Start
120								return pDate.Layout(gtx)
121							}),
122							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
123								pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(postDate))
124								pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
125								pDate.Alignment = text.End
126								return pDate.Layout(gtx)
127							}),
128						)
129					}),
130
131					layout.Rigid(func(gtx layout.Context) layout.Dimensions {
132						return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
133							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
134								eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Edited: ")
135								eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
136								eDate.Alignment = text.Start
137								return eDate.Layout(gtx)
138							}),
139							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
140								eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(dateEdited))
141								eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
142								eDate.Alignment = text.End
143								return eDate.Layout(gtx)
144							}),
145						)
146					}),
147				)
148			}),
149
150			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
151				size := image.Pt(gtx.Constraints.Max.X, 4)
152				defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
153				paint.ColorOp{Color: color.NRGBA{A: 0xFF}}.Add(gtx.Ops)
154				paint.PaintOp{}.Add(gtx.Ops)
155				return layout.Dimensions{Size: size}
156			}),
157
158			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
159				gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .9)
160				gtx.Constraints.Min.X = gtx.Constraints.Max.X
161				sTitle := material.Label(th, unit.Sp(float32(*flagSiteTitleSize)), siteTitle)
162				sTitle.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
163				sTitle.Alignment = text.Middle
164				return sTitle.Layout(gtx)
165			}),
166			layout.Rigid(layout.Spacer{Height: 50}.Layout),
167		)
168	})
169	w.Frame(gtx.Ops)
170	img := image.NewRGBA(image.Rectangle{Max: sz})
171	err = w.Screenshot(img)
172	if err != nil {
173		fmt.Println("Error: Could not transfer GUI to image")
174	}
175	var buf bytes.Buffer
176	if err := png.Encode(&buf, img); err != nil {
177		fmt.Println("Error: Could not encode image to PNG")
178	}
179	ioutil.WriteFile(*flagOutput, buf.Bytes(), 0o640)
180
181	os.Exit(0)
182}
183
184func fontCollection() []text.FontFace {
185	regularFaceBytes, err := fontEmbed.ReadFile("fonts/regular.otf")
186	if err != nil {
187		fmt.Println("Error: Could not read regular font")
188		fmt.Println(err)
189		os.Exit(1)
190	}
191	regularFace, err := opentype.Parse(regularFaceBytes)
192	if err != nil {
193		fmt.Println("Error: Could not parse regular font")
194		fmt.Println(err)
195		os.Exit(1)
196	}
197
198	boldFaceBytes, err := fontEmbed.ReadFile("fonts/bold.otf")
199	if err != nil {
200		fmt.Println("Error: Could not read bold font")
201		fmt.Println(err)
202		os.Exit(1)
203	}
204	boldFace, err := opentype.Parse(boldFaceBytes)
205	if err != nil {
206		fmt.Println("Error: Could not parse bold font")
207		fmt.Println(err)
208		os.Exit(1)
209	}
210
211	italicFaceBytes, err := fontEmbed.ReadFile("fonts/italic.otf")
212	if err != nil {
213		fmt.Println("Error: Could not read italic font")
214		fmt.Println(err)
215		os.Exit(1)
216	}
217	italicFace, err := opentype.Parse(italicFaceBytes)
218	if err != nil {
219		fmt.Println("Error: Could not parse italic font")
220		fmt.Println(err)
221		os.Exit(1)
222	}
223
224	boldItalicFaceBytes, err := fontEmbed.ReadFile("fonts/bold-italic.otf")
225	if err != nil {
226		fmt.Println("Error: Could not read bold italic font")
227		fmt.Println(err)
228		os.Exit(1)
229	}
230	boldItalicFace, err := opentype.Parse(boldItalicFaceBytes)
231	if err != nil {
232		fmt.Println("Error: Could not parse bold italic font")
233		fmt.Println(err)
234		os.Exit(1)
235	}
236
237	return []text.FontFace{
238		{
239			Font: text.Font{
240				Typeface: "Primary font",
241				Variant:  "",
242				Style:    text.Regular,
243				Weight:   text.Normal,
244			},
245			Face: regularFace,
246		},
247		{
248			Font: text.Font{
249				Typeface: "Primary font",
250				Variant:  "",
251				Style:    text.Regular,
252				Weight:   text.Bold,
253			},
254			Face: boldFace,
255		},
256		{
257			Font: text.Font{
258				Typeface: "Primary font",
259				Variant:  "",
260				Style:    text.Italic,
261				Weight:   text.Normal,
262			},
263			Face: italicFace,
264		},
265		{
266			Font: text.Font{
267				Typeface: "Primary font",
268				Variant:  "",
269				Style:    text.Italic,
270				Weight:   text.Bold,
271			},
272			Face: boldItalicFace,
273		},
274	}
275}
276
277// Print help message
278func help() {
279	fmt.Println("\nUsage: p2c [options]")
280	fmt.Println("\nOptions:")
281	flag.PrintDefaults()
282	fmt.Print(`
283example: p2c -i input.md -o output.png
284
285p2c is meant for use with Hugo.
286
287It looks at...
288- The Markdown file's frontmatter fields for
289  - title
290  - date
291- The site's config.{toml/yaml/yml} fields for
292  - title (site title)
293- The git history to determine the date the post was last edited.
294
295`)
296}
297
298// Validate flags
299func validateFlags() {
300	if *flagInput == "" {
301		fmt.Println("Error: No input file specified")
302		os.Exit(1)
303	}
304	if *flagOutput == "" {
305		fmt.Println("Error: No output file specified")
306		os.Exit(1)
307	}
308}
309
310// Get the post's title, subtitle, and date
311func getPostInfo(input string) (string, string, string) {
312	if _, err := os.Stat(input); os.IsNotExist(err) {
313		fmt.Println("Error: Input file does not exist")
314		fmt.Println(err)
315		os.Exit(1)
316	}
317
318	fileContents, err := os.ReadFile(input)
319	if err != nil {
320		fmt.Println("Error: Could not read input file")
321		fmt.Println(err)
322		os.Exit(1)
323	}
324
325	var fm struct {
326		Title string `yaml:"title"`
327		Date  string `yaml:"date"`
328	}
329
330	content, err := frontmatter.Parse(strings.NewReader(string(fileContents)), &fm)
331	if err != nil {
332		fmt.Println("Error: Could not parse frontmatter")
333		fmt.Println(err)
334		os.Exit(1)
335	}
336
337	fm.Date = strings.Split(fm.Date, "T")[0]
338
339	return fm.Title, fm.Date, string(content)
340}
341
342// Get the read time of the post
343func getReadTime(content string) int {
344	wordCount := len(strings.Fields(content))
345	return wordCount / 200
346}
347
348// Get the site's title
349func getSiteTitle() string {
350	validConf := ""
351	confs := []string{"config.toml", "config.yaml", "config.yml"}
352	for _, conf := range confs {
353		if _, err := os.Stat(conf); os.IsNotExist(err) {
354			continue
355		}
356		validConf = conf
357	}
358	if validConf == "" {
359		fmt.Println("Error: No valid config file found, please run this in the root of a Hugo site")
360		os.Exit(1)
361	}
362
363	var t struct {
364		Title string `yaml:"title"`
365	}
366
367	f, err := os.Open(validConf)
368	if err != nil {
369		fmt.Println("Error: Could not open config file")
370		fmt.Println(err)
371		os.Exit(1)
372	}
373	defer f.Close()
374
375	decoder := yaml.NewDecoder(f)
376	err = decoder.Decode(&t)
377	if err != nil {
378		fmt.Println("Error: Could not parse config file")
379		fmt.Println(err)
380		os.Exit(1)
381	}
382
383	return t.Title
384}
385
386// Get the date the post was last edited
387func getGitDate(input string) string {
388	if _, err := os.Stat(input); os.IsNotExist(err) {
389		fmt.Println("Error: Input file does not exist")
390		os.Exit(1)
391	}
392
393	repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true})
394	if err != nil {
395		fmt.Println("Error: Could not open git repository")
396		fmt.Println(err)
397		os.Exit(1)
398	}
399
400	commitIter, err := repo.Log(&git.LogOptions{FileName: &input})
401	if err != nil {
402		fmt.Println("Error: Could not get git history")
403		fmt.Println(err)
404		os.Exit(1)
405	}
406
407	commit, err := commitIter.Next()
408	if err != nil {
409		fmt.Println("Error: Could not get git history")
410		fmt.Println(err)
411		os.Exit(1)
412	}
413
414	return commit.Committer.When.Format("2006-01-02")
415}