Skip to content

Instantly share code, notes, and snippets.

@istupakov
Last active July 14, 2021 10:17
Show Gist options
  • Save istupakov/55079525fdbe9357852014f05f1462ca to your computer and use it in GitHub Desktop.
Save istupakov/55079525fdbe9357852014f05f1462ca to your computer and use it in GitHub Desktop.
Word to QTI Converter
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using System.Xml;
using System.Xml.Linq;
using Word = Microsoft.Office.Interop.Word;
namespace WordToExam
{
internal enum AssessmentType { None, Single, Multi, Text, Number, Pairs };
internal abstract class Assessment
{
public string Id { get; } = Program.RandomString(10);
public int Part { get; set; }
public string Title { get; set; }
public string Text { get; set; }
public float Score { get; set; }
public string Section { get; set; }
public string Identifier => $"Item_{Id}";
public string Filename => $"item_{Id}.xml";
public List<SimpleChoice> Choices { get; } = new List<SimpleChoice>();
protected abstract XElement ResponseXml();
protected abstract XElement BodyXml();
public XDocument ToXml()
{
return new XDocument(
new XElement(XName.Get("assessmentItem", "http://www.imsglobal.org/xsd/imsqti_v2p1"),
new XAttribute("identifier", Identifier),
new XAttribute("title", Title),
new XAttribute("adaptive", "false"),
new XAttribute("timeDependent", "false"),
new XAttribute("toolName", "SuperPuperConverter"),
new XAttribute("toolVersion", "0.1.0.0"),
ResponseXml(),
BodyXml()
)
);
}
public XElement ToXmlRef()
{
return new XElement("assessmentItemRef",
new XAttribute("identifier", Identifier),
new XAttribute("href", Filename),
new XElement("itemSessionControl", new XAttribute("showSolution", true))
);
}
public static Assessment Create(AssessmentType type)
{
switch (type)
{
case AssessmentType.Single:
return new ChoiceAssessment() { Single = true };
case AssessmentType.Multi:
return new ChoiceAssessment() { Single = false };
case AssessmentType.Text:
return new TextAssessment() { Number = false };
case AssessmentType.Number:
return new TextAssessment() { Number = true };
case AssessmentType.Pairs:
return new PairsAssessment() { Single = false };
default:
throw new NotSupportedException();
}
}
}
internal class ChoiceAssessment : Assessment
{
public bool Single { get; set; }
protected override XElement ResponseXml()
{
int correctCount = Choices.Count(c => c.Correct);
return new XElement("responseDeclaration",
new XAttribute("identifier", "RESPONSE"),
new XAttribute("cardinality", Single ? "single" : "multiple"),
new XAttribute("baseType", "identifier"),
new XElement("correctResponse", Choices.Where(c => c.Correct).Select(c => new XElement("value", c.Id))),
new XElement("mapping", new object[] {
new XAttribute("lowerBound", 0),
new XAttribute("upperBound", Score),
new XAttribute("defaultValue", 0)
}.Concat(Choices.Select(c => c.MappingXml((c.Correct ? +1 : -1) * Score / correctCount))))
);
}
protected override XElement BodyXml()
{
return new XElement("itemBody",
new XElement("choiceInteraction", new object[] {
new XAttribute("responseIdentifier", "RESPONSE"),
new XAttribute("shuffle", "true"),
new XAttribute("maxChoices", Single ? 1: 0),
new XElement("prompt", new XCData(Text)),
}.Concat(Choices.Select(c => c.ToXml())))
);
}
}
internal class TextAssessment : Assessment
{
public bool Number { get; set; }
protected override XElement ResponseXml()
{
var mapping = Number ? Enumerable.Empty<object>() : Choices.Select(c => c.MappingXml(Score));
return new XElement("responseDeclaration",
new XAttribute("identifier", "RESPONSE"),
new XAttribute("cardinality", "single"),
new XAttribute("baseType", "string"),
new XElement("correctResponse", Choices.Select(c => new XElement("value", c.Text))),
new XElement("mapping", new object[] {
new XAttribute("lowerBound", 0),
new XAttribute("upperBound", Score),
new XAttribute("defaultValue", 0)
}.Concat(mapping))
);
}
protected override XElement BodyXml()
{
return new XElement("itemBody",
new XElement("blockquote", new XCData(Text)),
new XElement("textEntryInteraction",
new XAttribute("responseIdentifier", "RESPONSE"),
new XAttribute("expectedLength", 20)
)
);
}
}
internal class PairsAssessment : ChoiceAssessment
{
protected override XElement ResponseXml()
{
var pairs = from c1 in Choices
from c2 in Choices
let correct = c1.Name == c2.Name && c1.Correct && c2.Correct
where c1.Id.CompareTo(c2.Id) == -1
select (key: $"{c1.Id} {c2.Id}", correct: correct);
var correctCount = pairs.Count(p => p.correct);
return new XElement("responseDeclaration",
new XAttribute("identifier", "RESPONSE"),
new XAttribute("cardinality", "multiple"),
new XAttribute("baseType", "pair"),
new XElement("correctResponse", pairs.Where(p => p.correct).Select(p => new XElement("value", p.key))),
new XElement("mapping", new object[] {
new XAttribute("lowerBound", 0),
new XAttribute("upperBound", Score),
new XAttribute("defaultValue", -Score / correctCount)
}.Concat(pairs.Select(p => new XElement("mapEntry",
new XAttribute("mapKey", p.key),
new XAttribute("mappedValue", (p.correct ? +1 : -1) * Score / correctCount)
)
)))
);
}
protected override XElement BodyXml()
{
return new XElement("itemBody",
new XElement("associateInteraction", new object[] {
new XAttribute("responseIdentifier", "RESPONSE"),
new XAttribute("shuffle", "true"),
new XAttribute("maxAssociations", Choices.Count() / 2),
new XElement("prompt", new XCData(Text)),
}.Concat(Choices.Select(c =>
new XElement("simpleAssociableChoice",
new XAttribute("identifier", c.Id),
new XAttribute("matchMax", 1),
new XCData(c.Text)))))
);
}
}
internal class SimpleChoice
{
public string Id { get; } = Program.RandomString(10);
public string Name { get; set; }
public string Text { get; set; }
public bool Correct { get; set; }
public XElement ToXml()
{
return new XElement("simpleChoice", new XAttribute("identifier", Id), new XCData(Text));
}
public XElement MappingXml(double value)
{
return new XElement("mapEntry", new XAttribute("mapKey", Id), new XAttribute("mappedValue", value));
}
}
internal class Test
{
private ZipArchive zip;
private List<Assessment> list = new List<Assessment>();
public Test(Stream stream)
{
zip = new ZipArchive(stream, ZipArchiveMode.Create);
}
private Assessment ReadAssessment(AssessmentType type, int part, string section, string title, float score, ref Word.Paragraph cur)
{
var doc = (Word.Document)cur.Parent;
if (string.IsNullOrWhiteSpace(cur.Range.Text))
cur = cur.Next();
var ass = Assessment.Create(type);
ass.Part = part;
ass.Section = section;
ass.Title = title;
ass.Score = score;
var imagesCount = 0;
string ReadParagraph(Word.Paragraph p, bool lowRes)
{
var content = new StringBuilder();
var last = p.Range.Start;
foreach (var i in p.Range.InlineShapes.OfType<Word.InlineShape>())
{
var filename = $"image_{ass.Id}_{imagesCount++}.png";
var entry = zip.CreateEntry(filename);
if (last != i.Range.Start)
content.Append(doc.Range(last, i.Range.Start).Text);
var width = doc.Application.PointsToPixels(i.Width);
var height = doc.Application.PointsToPixels(i.Height, true);
content.Append($"<img src=\"{filename}\" width=\"{width}\" height=\"{height}\" />");
using (var fileStream = entry.Open())
{
if (i.Type != Word.WdInlineShapeType.wdInlineShapePicture)
{
using (var ms = new MemoryStream(i.Range.EnhMetaFileBits))
{
var mf = Metafile.FromStream(ms);
if (lowRes)
new Bitmap(mf, (int)width, (int)height).Save(fileStream, ImageFormat.Png);
else
mf.Save(fileStream, ImageFormat.Png);
}
}
else
{
i.Range.Copy();
Clipboard.GetImage().Save(fileStream, ImageFormat.Png);
}
}
last = i.Range.End;
}
if (last != p.Range.End)
content.Append(doc.Range(last, p.Range.End).Text.TrimEnd('\r', '\n', '\t', ' '));
return content.ToString();
}
ass.Text = $"<p>{ReadParagraph(cur, false).Replace("\x0B", "</p><p>")}</p>";
Console.WriteLine($"{part}: {ass.Title}. {type} ({ass.Score}): {ass.Text}");
cur = cur.Next();
while (cur != null && Regex.IsMatch(cur.Range.Text, @"^\s*\d+[\)\]]\s"))
{
foreach (var cm in Regex.Matches(ReadParagraph(cur, type != AssessmentType.Pairs), @"(\d+)([\)\]])\s+(.+?)\s*(?=\d+([\)\]])\s+|$)").OfType<Match>())
{
var c = new SimpleChoice { Text = cm.Groups[3].Value, Name = cm.Groups[1].Value, Correct = cm.Groups[2].Value == "]" };
ass.Choices.Add(c);
Console.WriteLine($"{(c.Correct ? "C" : "W")}: {c.Text}");
}
cur = cur.Next();
}
return ass;
}
public void ReadData(string headerFile, string wordFile)
{
var wordApp = new Word.Application();
var doc = wordApp.Documents.Open(wordFile, ReadOnly: true);
var headers = File.ReadLines(headerFile);
var comment = Path.GetFileNameWithoutExtension(wordFile).ToLowerInvariant();
try
{
var curr = doc.Paragraphs.First;
foreach (var header in headers)
{
var match = Regex.Match(header, @"^(\w+)\s+P(\d+)\s+N(\w+)\s+S([0-9.]+)\s*$");
if (!match.Success)
throw new InvalidOperationException();
var type = (AssessmentType)Enum.Parse(typeof(AssessmentType), match.Groups[1].Value);
var part = int.Parse(match.Groups[2].Value);
var name = match.Groups[3].Value;
var score = float.Parse(match.Groups[4].Value);
list.Add(ReadAssessment(type, part, $"Вопрос {name}", $"Вопрос {name} ({comment})", score, ref curr));
}
}
finally
{
doc.Close();
}
}
private XElement CreateSection(IEnumerable<Assessment> section, string title)
{
return new XElement("assessmentSection", new object[] {
new XAttribute("identifier", $"Section_{Program.RandomString(10)}"),
new XAttribute("title", title),
new XAttribute("visible", "true"),
new XElement("selection", new XAttribute("shuffle", "1")),
new XElement("ordering", new XAttribute("shuffle", "true"))
}.Concat(section.Select(a => a.ToXmlRef())));
}
private XElement CreatePart(IEnumerable<Assessment> part)
{
var sections = part.GroupBy(a => a.Section).Select(s => CreateSection(s, s.Key));
return new XElement("testPart", new object[] {
new XAttribute("identifier", $"Part_{Program.RandomString(10)}"),
new XAttribute("navigationMode", "linear"),
new XAttribute("submissionMode", "simultaneous"),
new XElement("ordering", new XAttribute("shuffle", "false")),
}.Concat(sections));
}
private XDocument CreateTest(string id, string title, TimeSpan maxTime)
{
var parts = list.GroupBy(a => a.Part).Select(p => CreatePart(p));
return new XDocument(
new XElement(XName.Get("assessmentTest", "http://www.imsglobal.org/xsd/imsqti_v2p1"), new object[] {
new XAttribute("identifier", $"Test_{id}"),
new XAttribute("title", title),
new XAttribute("toolName", "SuperPuperConverter"),
new XAttribute("toolVersion", "0.1.0.0"),
new XElement("timeLimits", new XAttribute("minTime", "0"), new XAttribute("maxTime", maxTime.TotalSeconds)),
}.Concat(parts))
);
}
public void WriteTest(string title, TimeSpan maxTime)
{
var id = Program.RandomString(10);
var doc = CreateTest(id, title, maxTime);
var testEntry = zip.CreateEntry($"test_{id}.xml");
using (var stream = testEntry.Open())
using (var writer = new XmlTextWriter(stream, new UTF8Encoding(false)))
{
doc.Save(writer);
}
foreach (var ass in list)
{
var itemEntry = zip.CreateEntry(ass.Filename);
using (var stream = itemEntry.Open())
using (var writer = new XmlTextWriter(stream, new UTF8Encoding(false)))
ass.ToXml().Save(writer);
}
zip.Dispose();
}
}
internal class Program
{
private static Random random = new Random();
public static string RandomString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
return new string(Enumerable.Repeat(chars, length)
.Select(s => s[random.Next(s.Length)]).ToArray());
}
[STAThread]
private static void Main(string[] args)
{
Environment.CurrentDirectory = @"I:\Stupakov\### 2019.03.26";
using (var file = File.Create("NN.zip"))
{
var test = new Test(file);
test.ReadData("header.txt", @"I:\Stupakov\### 2019.03.26\Вариант 1.docx");
test.ReadData("header.txt", @"I:\Stupakov\### 2019.03.26\Вариант 2.docx");
test.WriteTest("Нейросети", TimeSpan.FromMinutes(20));
}
}
}
}
@vjanomolee
Copy link

Hi. How would I go about using this script to convert a word document to qti format?

@songravita
Copy link

you can give me template doc, txt file, i need this to convert quiz import in canvas lms, help me?

@delzac
Copy link

delzac commented Jul 14, 2021

You can consider GETMARKED DIgitaliser they have a Word to QTi converter too. Works on any Word document. No need to even conform to any specific format or layout.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment